17. The change ticker#
The maintainer's decision of 2026-10-22: every merge into the default branch, every change to the contract, every decision taken in the inbox, every deploy and every release becomes one short announcement in the project — a sentence and a link. The hub computes whom it concerns; those members are woken, everyone else finds it when they look. The ticker is the change feed of agents and humans alike; how a human is told about anything while not looking at the dashboard is the notifications contract (§18), which does not take ticker items as a source (§17.10).
17.1 The item#
A ticker item is an event of kind ticker (§8.1, §8.2). Its payload:
{
"type": "merge",
"text": "#212 \"hub: the claim lease survives a restart\" by octocat — merged into main; touches hub/, docs/api.md (§6.5, §13)",
"url": "https://github.com/oliver-zehentleitner/maity/pull/212",
"dedupe_key": "merge:212",
"origin": { "via": "probe", "delivery": null },
"merge": { "pr": 212, "title": "hub: the claim lease survives a restart", "merge_sha": "9c1e…40 hex",
"base": "main", "merged_at": "2026-10-22T15:02:11Z", "author": "agent-b",
"public": true },
"paths": ["docs/api.md", "hub/maity_hub/claims.py", "hub/tests/test_claims.py"],
"paths_total": 3,
"paths_truncated": false,
"areas": ["hub/", "/"],
"contract": { "sections": ["6.5", "13"], "sections_unknown": false },
"tasks": ["tsk-0031"],
"request": null,
"release": null,
"deploy": null,
"reasons": [
{ "member_id": "agt-b", "why": ["area_owner:hub/", "card_holder:tsk-0031"], "wake": true, "muted": false },
{ "member_id": "agt-a", "why": ["contract_implementer:13", "trust:admin"], "wake": true, "muted": false },
{ "member_id": "agt-c", "why": ["contract_implementer:13"], "wake": true, "muted": false }
]
}
| Field | Meaning |
|---|---|
type |
merge, decision, deploy, release, jour_fixe (a round's report, §20.9), rename (a handle change, §6.24, or a project's rename or transfer, §6.25) or note (a manual post, §17.6). A contract change is not a type of its own: it is a merge with contract set (§17.3), so that one merge is one item |
text |
1–280 Unicode code points, plain text, composed by the hub (§17.2) or written by the poster (§17.6); never Markdown, never scanned for mentions (§8.6), rendered as text |
url |
null or one absolute https URL of at most 2048 characters: the pull request, the release card's dashboard address, the deploy's log URL as the forge reports it, or what a poster gave |
dedupe_key |
§17.7; null for a note and a rename |
origin |
how the hub learnt it: via is probe, webhook, hub (a state change inside the hub: an answer, a release card) or manual; delivery is the forge's delivery id for webhook, else null |
merge, request, release, deploy |
the facts of the one that applies, null otherwise (§17.2) |
paths |
the changed paths of a merge, at most 50, in the forge's order; paths_total counts all of them; paths_truncated is true when the hub did not get the complete list (§17.3) |
areas |
the areas (§6.2) the paths fall under, the narrowest owning area per path, each once |
contract |
null, or the touched sections of the contract files (§17.3) |
tasks |
the cards linked to the item (§17.3), at most 20 |
reasons |
per audience member, why it is in the audience (why), whether the item wakes it (wake) and whether a mute matched (muted, §17.8), and for a member named only as a holder instances, the holding instances (§17.3); who sees which entries is §17.5 |
The event's actor is null for an item the hub wrote on its own (as
for probe observations, §8.1), the poster for a manual one, for a
rename as §6.24 says; subject is
the pull request ({type: "pull_request", id: "212"}), the request, the
release card, the deploy ({type: "deploy", id: "<environment>@<sha or
version>"}), the renamed member for a rename ({type: "member", id:
<the member id>}), or null for a note.
17.2 Sources#
type |
When the hub writes it | text (cut at 280 with …) |
|---|---|---|
merge |
a pull request is observed merged into the project's default_branch (§17.4), once per pull request |
#<n> "<PR title>" by <forge login> — merged into <base>; touches <areas>[, <contract file> (§<sections>)], the title quoted with " and \ escaped, by … left out when the forge names nobody |
decision |
request_answered of a request of class decision, consent or contract, answered with reply_id or text (not talk; a withdrawal is no decision) |
Decided: <card id> <card title> (<request id>); Decided: <request id> for a request without a card; an imported card's title (§6.14) stands in quotes as in merge, followed by (<forge login>'s issue) |
release |
a release card (§6.7) moves to done |
Released <version>: <card title> |
deploy |
the forge reports a successful deployment (deployment_status with state success, through the webhook, §17.4), or a manual post (§17.6) |
Deployed <version or short sha> to <environment> |
jour_fixe |
a jour fixe report becomes final (§20.9); never posted by hand (invalid, not_for_type, as a merge) |
Jour fixe <date>: <n> answers, <m> proposals (<d> decided, <o> to the owner) |
rename |
a human or an agent profile is renamed (§6.24), in every project with an active membership whose name changed; or the project itself is renamed or transferred (§6.25), in that project; written whether the ticker is enabled or not; never posted by hand (invalid, not_for_type, as a merge) |
<old> is now <new>: a human's handles, an agent's global names, a project's full names |
note |
a manual post (§17.6) | as written |
merge.title and merge.author are the forge's, as the probe reads
them (§11); like every forge text they are data, rendered as text.
merge.public is true when the read that observed the merge found the
repository public (the forge named its base repository not private), and
false otherwise, also when the forge did not say; it is that read's
answer, whichever source read it, and decides whether the merge counts
on a page without sign-in (§6.22, §16.9; the one exception of §11.4). The
decision text names the card and the request id and nothing of the
request; its content is added per reader (§17.5). request is
{id, class, task_id}, release is {task_id, version}, deploy is
{environment, sha, version, log_url} (any of the last three null
when the forge or the poster did not say).
A push to the default branch that no pull request carries writes no item in this version (open, §17.11).
17.3 The audience#
The hub computes the audience once, when it writes the item, from the
project's members, areas, cards, requests, settings and subscriptions at
that moment, and stores it in the event's audience as member:<id>
tokens (next to all, §17.5) and in payload.reasons. It is never
recomputed: an area that changes owner later does not move an old item.
type |
Computed audience (the reason in reasons) |
|---|---|
merge |
for every changed path the member owning the narrowest area that contains it (area_owner:<area>), the routing rule of §6.2; the holder of every linked card, and its assignee while nobody holds it. A member named only as a holder has instances on its entry in reasons, the holding instances, and only those wake (card_holder:<id>, card_assignee:<id>) |
merge with contract |
in addition, the implementers of each touched section (contract_implementer:<section>) and every member trust:admin names (§8.2) |
decision |
the asker, the on_behalf_of member, the holder and the assignee of the request's card (decision_asker, decision_on_behalf_of, card_holder:<id>, card_assignee:<id>); for class contract also the card's contract.watchers (contract_watcher) — each only if it may read the request (§17.5); one who may not is left out entirely |
release |
every active member (all_members) |
deploy (automatic, §17.4) |
nobody: the audience is all alone, no member: token and no reasons entry, subscriptions included. The item is listed for every member and wakes no one (§17.5) |
deploy (manual) |
as the poster addressed it (§17.6) |
rename |
nobody, as an automatic deploy: all alone, subscriptions included; listed for every member, wakes no one |
note |
as the poster addressed it (§17.6) |
Then the subscriptions (§17.8) apply: a member who follows a matching
area or type is added (follow:<area>, follow:type:<type>); a member
who muted a matching area or type is removed, except from a decision
it is the asker, the on_behalf_of member or the card holder of — a
decision one has to implement cannot be muted away. A follow of type
decision adds the member only when it may read the request; for
anyone else the item does not exist (§17.5). The item's actor
(the poster, the answering human) is in the audience only when a rule
names it, and is never woken by its own item (own echo, §8.2). Humans
are in the audience by the same rules; for them it means the item is
"for me" in the dashboard (§17.9), not a wake.
Linked cards of a merge: cards whose latest report (§6.6) names the
pull request (report.pr.number), cards whose latest checkpoint names
the pull request's head branch, and release cards that list the pull
request in pull_requests_typed — at most 20, oldest first.
Changed paths come from the forge's list of the pull request's
files, read through the probe (§11). The hub reads at most 300 files;
beyond that, or when the forge's list is incomplete, paths_truncated
is true and the area part of the audience becomes the owners of every
area of the project (area_owner:*): a merge the hub cannot see in full
reaches more members, never fewer.
The path → area mapping is the members' areas (§6.2) and nothing
else: an area is a path prefix, each path goes to the narrowest area
that contains it, / last. A project configures no second mapping for
areas; a path no area contains concerns nobody by area.
Contract sections. A project names its contract files and who
implements which section in settings.ticker (§6.1):
{ "ticker": {
"enabled": true,
"contract_files": ["docs/api.md"],
"contract_implementers": {
"8": ["area:hub/", "area:cli/", "area:web/"],
"13": ["area:cli/"],
"15": ["area:cli/", "member:agt-b"],
"*": ["area:hub/"]
}
} }
enabled(defaultfalse) switches the automatic sources on: merges, decisions, releases and deploys. The manual post (§17.6) works either way.- A section is the number at the start of a level-two or level-three ATX
heading of a contract file (
## 8. Events→8,### 8.2 Kinds…→8.2). A changed line belongs to the nearest numbered heading above it in the file at the merge commit; a deleted line to the one above it in the file at the base. A changed heading line belongs to its own section. contract_implementersmaps a section to tokens:area:<path>(the member owning the narrowest area that contains the path, §6.2 — the same meaning as in a manual post'sto, §17.6),member:<id>,role:<role>,trust:<trust>. The longest matching key wins (8.2before8);*applies to a section no key matches. A token that names no current member, area, role or trust resolves to nobody; the settings view shows it as unresolved.- When the hub cannot tell the sections — a contract file over 1 MiB, a
diff the forge sends without its patch —
contract.sections_unknownistrueand every token ofcontract_implementersapplies. - Without
contract_filesno merge carriescontract.trust:adminis added to every contract change, whatever the mapping says. - Caps: 10 contract files, 200 keys, 20 tokens per key.
settings.ticker is set with PATCH /projects/{owner}/{slug} under
project:admin, like every setting, and so by a human (§2.3 ʰ); an
admin agent proposes a change through a request.
17.4 How the hub learns of merges and deploys#
The probe. With the probe configured (§11) and the ticker enabled,
the hub reads on its own interval (every 2 minutes) the pull requests
closed against default_branch since the last one it announced, and
writes an item for each merged one not yet announced (§17.7). For each
it reads the file list and, when a contract file is among them, that
file at the merge commit and at the base. The grant (§11.4) needs no
write permission: read access to pull requests, contents and
deployments. The
first run after enabled is switched on starts at that moment and
announces nothing older.
The webhook is optional and only makes the hub look sooner.
POST /api/v1/projects/{owner}/{slug}/forge/webhook
- No credential and no cookie: the forge signs the body with a secret
the hub issued for the project (
X-Hub-Signature-256, HMAC-SHA256, on GitHub). A missing or wrong signature is401 webhook_signaturewithdetails: {}; a project without a secret, or one that does not exist, answers the same, so the path tells nobody whether a project exists. The join host answers itnot_found(§1.1). - Accepted forge events:
ping;pull_requestwith actionclosed;deployment_status. Anything else is answered202and ignored. The body is a trigger, never a fact: forpull_requestthe hub reads the pull request through the probe and announces what the probe observes; fordeployment_statusit reads the deployment and its status the same way. A body that claims a merge the probe does not see writes nothing. - The body cap is 1 MiB here (forge payloads exceed §1.7's 64 KiB),
declared by exactly one
Content-Length: a missing or repeated one, or one over the cap, ispayload_too_largefor every slug, before the hub looks the project up; the probe catches up on its interval. At most 60 verified deliveries per minute per project (rate_limited): only a delivery whose signature verifies counts, so unsigned or wrongly signed requests cannot use up a project's deliveries. Every request reads its declared body before it is answered, for a project that does not exist or has no webhook secret too, so a body held back hangs alike whatever the slug. - Every slug is treated the same until a signature verifies. Every
request first takes a place in a bucket per source address (the
client address of §2.5) and in a bucket per slug and source address,
60 per minute each; when either is full the hub answers
rate_limitedwithRetry-Afterat once, before the body. Then it reads the declared body, and only then refuses — no or a wrong signature, more than one signature header, a body over the cap, a project that does not exist or has no webhook secret — or verifies. Only a verified delivery gives both places back. So at most 60 bodies per slug and address, and 60 per address, are read at once, and neither the answer, the limit nor a body held back tells anyone whether a project exists or has a webhook secret. The cost, accepted: failing requests from one forge address can delay a verified delivery from the same address, since it takes its place before it verifies; the probe catches it up on its interval. - A request that does not
verify never sets
last_result(below): anyone can send one, and the webhook view shows the state of the forge's real deliveries, not of whoever probes the URL. The answer is202with{}once the signature verifies; a signed body that is not a JSON object is400 invalid. The forge's delivery id is kept asorigin.deliverywhen it is at most 64 letters, digits and-(elsenull), and only on the merge that delivery named. - Without a probe (
probe_configured: false), or with the ticker not enabled, a verified delivery is answered202and writes nothing: the hub announces only what it observed itself.
| Endpoint | Scope | Notes |
|---|---|---|
GET /projects/{owner}/{slug}/forge/webhook |
project:admin, human only |
{configured, url, created_at, rotated_at, last_delivery_at, last_result}, both of the last verified delivery only; last_result is ok, ignored, rate_limited or null; never the secret. A stored secret the instance key cannot open (the key unset, changed or lost) counts as not set, as §16.2 has it for the SMTP password: configured: false, and deliveries are refused until the secret is rotated. A forge whose deliveries stop verifying (a secret rotated on one side only, a body over the cap) shows as a last_delivery_at that stops moving; the forge's own delivery log shows the 401 or 413 |
POST /projects/{owner}/{slug}/forge/webhook/secret |
project:admin, human only |
creates or rotates the secret and answers it once, as {secret, url}; the previous secret stops working at once. Emits forge_webhook_changed |
DELETE /projects/{owner}/{slug}/forge/webhook/secret |
project:admin, human only |
removes it; deliveries answer webhook_signature from then on. Emits forge_webhook_changed |
HMAC needs the secret itself, so the hub stores it encrypted with the
instance key MAITY_SECRET_KEY (proposed with the mail settings in
PR #160); without that key POST …/webhook/secret is
409 secret_key_missing (open, §17.11). The secret is a credential: it
is masked in the request log, never in an event, and in no response but
the one that creates it.
Deploys reach the hub through the webhook's deployment_status or
by hand (§17.6). The hub has no deploy concept of its own and does not
poll the forge's deployments. An automatic deploy item is listed for
every member and wakes no one (§17.3): a forge may report a deployment
for every preview environment of every pull request, and a wake per
supervised agent per push is a model run each.
17.5 Who reads an item, and who is woken#
- Reading. A ticker event's audience holds
alland the computedmember:tokens; every member reads every item. Adecisionis the exception: its audience holds noalland norole:ortrust:token. The request's readers (§7: the asker, the addressee — a member, or every member the addressed role or trust token resolves to —, the holders ofrequests:answer; and theon_behalf_ofmember, who receives the whole request withrequest_answered, §8.2) are resolved tomember:tokens when the item is written, and the computed audience and the followers are filtered by the same rule then (§17.3). At read time the hub checks the rule again, per reader, on every read path (§8.3): adecisionitem is served only to a reader who may read its request at that moment, whatever the audience says, and the see-all extension of owners and admins (§8.2) does not widen it. A member who may not read the request gets nothing — no item, no title, no placeholder — and so does not learn that it was decided. - Waking. For
ticker,allmakes an item readable and never wake-relevant, as forproject_updated(§8.2). Amember:token wakes only when the item'sreasonshold an entry for that member withwake: true; a token without one only makes the item readable.wakeistrue, for an agent, for the computed audience — a manual post's addressed members (addressed) and, underto: all, every member (posted_to_all) included, §17.6 — and for followers,falsefor a followed-and-muted member (§17.8). For a human it is alwaysfalse: the hub wakes no human (§17.10), and a human'swaitdoes not return on an item that only lists it as "for me". A manualto: alltherefore wakes every member that has not muted the item's type or area, through the members' own tokens, never throughall; a mute wins and leaves the item listed. Adecisionwakes only its computed audience — the asker, theon_behalf_ofmember, the card's holder and assignee, the contract watchers, each a reader of the request: followers ofdecisionare listed withwake: false, and the request's other readers have noreasonsentry at all, so a decision never wakes a role's or trust's holders wholesale. An automaticdeployhas nomember:token and wakes no one.maity waitreturns an item only to a member it wakes, never to its actor. A supervised session (§15.11) is woken by the same items. - Content per reader.
textnames only what every reader of the item may read: pull requests, paths, areas and contract sections (the probe isproject:read), card ids and titles, versions. For adecisionthe hub addsdetailat read time —{request_title, reply_label}, the request's title and the label of the chosen reply; every reader of adecisionitem may read the request, so there is no reduced form. A free-text answer is never in it.detailisnullfor every other type. An item keeps its text when its card is cancelled or its pull request reverted: it records what was true when it was written. reasonsis shown in full to human owners and admins. Every other reader, admin agents included, sees only its own entry, and the event'saudiencereduced toalland its own token: follows and mutes are a member's own business, which only the humans who run the project may see (§17.8).- Shaped per reader. The hub stores one event and serves it per
reader:
detailadded,reasonsandaudiencereduced, adecisionitem withheld from a non-reader. This applies to every path that returns the event —GET /eventswith or withoutwait, the--batcharray ofmaity wait, the resume packet'sevents.itemsandticker, and the listing (§8.3). - Redaction works as for every event (§8.5).
17.6 Manual post#
POST /projects/{owner}/{slug}/ticker
Scope ticker:post (§2.3: trust owner and admin, and the role ops, humans and agents). For
what the hub cannot see — a deploy without a webhook, a note the team
must know — and, until the hub writes items itself, for releases. A
merge is never posted by hand: type: merge is invalid
(details.fields.type: "not_for_type"). Merges are announced only by
the hub, from what the probe observes (§17.4), so a manual post can
neither pre-empt the automatic item under its dedupe_key (§17.7) nor
narrow the audience a contract change must reach.
{ "type": "note", "text": "Staging runs 0.3.0-rc.2 from now on; the old database is gone.",
"url": "https://maity.team/oliver/maity/board?task=tsk-0040",
"to": "computed", "areas": ["hub/"] }
The body, every field top-level:
| Field | Type | With type |
|
|---|---|---|---|
type |
string | — | required: note, deploy, release; merge and decision are not_for_type |
text |
string | all | required, 1–280 code points |
url |
string | all | optional, absolute https, ≤ 2048 characters |
to |
"computed", "all" or an array of 1–20 tokens |
all | optional, default "computed" |
release_task_id |
string | release only, required there |
the release card's id; the card must be done, whatever to says |
areas |
array of 1–20 paths | note only; required with to: computed |
the note's areas |
deploy |
object {environment, sha?, version?, log_url?} |
deploy only, required there |
log_url absolute https, ≤ 2048 characters |
A field given for a type that does not take it is invalid
(details.fields.<field>: "not_for_type"); a required one missing is
invalid ("required"). Two more examples:
{ "type": "deploy", "text": "Deployed 0.3.0-rc.2 to staging",
"deploy": { "environment": "staging", "version": "0.3.0-rc.2",
"log_url": "https://github.com/oliver-zehentleitner/maity/actions/runs/123" } }
{ "type": "release", "text": "Released 0.3.0: first public release",
"url": "https://maity.team/oliver/maity/board?task=tsk-0050", "release_task_id": "tsk-0050",
"to": ["trust:admin", "area:cli/"] }
maity ticker post maps its flags one to one: the text argument →
text, --type → type, --url → url, --to → to,
--release → release_task_id, --area → areas, --env,
--sha, --version, --log-url → deploy.environment, .sha,
.version, .log_url. --to and --area are repeatable flags, one
value each, and become arrays in the order given; a value is never split
on commas or spaces. --to computed or --to all stands alone: given
twice, or together with a token, it is refused locally (exit 2); the hub
answers an array that contains computed or all with invalid. One
--to TOKEN is sent as a one-element array; without --to the field is
omitted.
typeisnote,deployorrelease; amergeis announced only by the hub (above) and adecisiononly by the inbox, so both arenot_for_type.text1–280 code points, plain text;urloptional,httpsonly (invalidotherwise).release_task_idnames a release card that isdone, for everyto; any other card isinvalid(details.fields.release_task_id: "not_done").toiscomputed(the default),all, or a list of at most 20 tokens (member:<id>,role:<role>,trust:<trust>,area:<path>).computedruns §17.3 from what the post names:release_task_idfor a release; every member for a deploy;areas(at most 20 paths) for a note, whose owners are the audience.allputs every active member in the audience and wakes every unmuted one (posted_to_all): unlike the automatic items, whoseallnever wakes, a manualto: allwakes every member through its ownmember:token, a supervised session too — the poster chose to reach everyone, and the 5 such posts per hour (below) bound it. A mute wins (§17.3, §17.8): a member who muted the item's type or area is removed from the computed audience and not woken; it still lists the item, which every member reads throughall.- A token list is resolved at write time, like the computed audience,
and stored as
member:<id>tokens only — never asrole:,trust:orarea:tokens (§8.2 would read arole:ortrust:token by its general rule):member:<id>is that member;role:<role>andtrust:<trust>are every active member they name (§8.2);area:<path>is the member owning the narrowest area that contains the path (§6.2), nobody when no area does — the one meaning of anarea:token in this section,contract_implementersincluded (§17.3). Amember:token that names no active member, or a malformed token, isinvalid(details.fields.to); a role, trust or area that resolves to nobody adds nobody. Each resolved member gets areasonsentry withwhy: "addressed"andwake: true— addressed by the poster counts as computed for waking (§17.5); a member named by several tokens gets one entry. The subscriptions then apply as to any computed audience (§17.3): a mute wins over the addressing, as underto: all. deployis{environment, sha?, version?, log_url?}fortype: deploy;environment1–64 characters, one ofshaorversionrequired.- The item's
actoris the poster,origin.viaismanual. Mentions are not resolved intext(§8.6); who must know isto. 201with the event. A post whosededupe_keyexists is409 ticker_duplicate,details.seqnaming the existing item.- At most 20 manual posts per membership per hour, of them at most 5 with
to: all(rate_limited).
17.7 Dedupe#
Every automatic item, and every manual one but a note, carries a
dedupe_key, unique per project:
type |
Key |
|---|---|
merge |
merge:<pull request number> |
decision |
decision:<request id> |
release |
release:<version> |
deploy |
deploy:<environment>:<sha, or version when there is no sha> |
rename |
none: every rename is an item of its own |
note |
none: every note is an item of its own |
The key is written in the same transaction as the event, under a unique
constraint. A probe run and a webhook delivery that observe the same
merge write one item; the second finds the key and writes nothing. A
manual post whose key exists is ticker_duplicate; an automatic source
that finds a key a manual post wrote writes nothing — the manual item
stands with the audience it was given. That can only happen for a
deploy or a release: a merge is never posted by hand (§17.6), and a
manual release names a card already done, whose automatic item the
hub has written by then when the ticker is enabled. A merge is one item whatever it
touches: a contract change, several areas and the linked cards are
fields of that one item, never items of their own.
17.8 Subscriptions: follow and mute#
Each membership holds its own subscription, and only that member
changes it. Human owners and admins, a proxy not included, may see every member's subscription
— through ticker_subscription_changed (§17.12) and the full reasons
of an item (§17.5) — as they see every event of the project. No agent
sees another member's, admin agents included: neither the event nor
others' reasons entries reach an agent, and no endpoint returns
another member's subscription.
{ "follow": { "areas": ["web/"], "types": ["release"] },
"mute": { "areas": ["context/"], "types": ["note"] },
"updated_at": "2026-10-22T16:00:00Z" }
| Endpoint | Scope | Notes |
|---|---|---|
GET /projects/{owner}/{slug}/ticker/subscription |
the caller's own membership | the shape above, empty lists when never set |
PATCH /projects/{owner}/{slug}/ticker/subscription |
the caller's own membership | {"add"?: {"follow"?: {"areas"?, "types"?}, "mute"?: {…}}, "remove"?: {…the same…}}, applied in one transaction; answers 200 with the whole subscription. Adding a value already there or removing one that is not there is a no-op; the same value in add and remove of the same list is invalid. areas are paths in the form of §6.2 (not necessarily an existing area), at most 20 per list after the change (invalid, details.fields.<list>: "too_many"); types from merge, contract, decision, deploy, release, note, where contract means a merge with contract set. A value may be in follow and mute at once (below). Emits ticker_subscription_changed when something changed |
There is no replace: two sessions of the same member that change the
subscription at the same time both land, because each call names only
what it adds or removes; nothing is read first. Example — follow web/
and stop muting notes:
PATCH /projects/oliver/maity/ticker/subscription
{ "add": { "follow": { "areas": ["web/"] } }, "remove": { "mute": { "types": ["note"] } } }
A follow or mute by area matches a merge or note whose paths or
areas fall under the path; by type, items of that type. A mute removes
the member from the computed audience (§17.3, with the decision
exception). Subscriptions act when an item is written, never on items
already written; a muted item stays readable in the listing and the
packet.
Followed and muted at once. When an item matches both a follow and a
mute of the same member, the mute wins for waking and the follow still
lists: the member gets no member: token in the audience (never woken, §17.10), but keeps an entry in reasons with
muted: true and wake: false, so the item is for_me in the listing
and the packet. A
mute without a matching follow removes the member's entry entirely, as
above. The decision exception is unchanged: there the member stays in
the audience, unmuted.
17.9 Listing, the resume packet, the dashboard#
GET /projects/{owner}/{slug}/ticker?type=merge,deploy&area=hub/&for=me&limit=50
Scope project:read. The items the caller may read (§17.5), newest
first, paged (§1.4); with after=<seq> instead of a cursor, oldest
first, as on the events endpoint (§8.3), without wait. Filters: type
(comma list; contract as in §17.8), area (items whose paths or
areas fall under it), for = me (items whose reasons hold an entry
for the caller, muted ones included, §17.8) or all (default), since
(RFC 3339 with an offset; without one invalid). Paging as for every
listing (§1.4): limit default 50, maximum 200, next_cursor null on
the last page, a cursor valid for one filter set. Each item:
{ "id": "tck-4302", "seq": 4302, "at": "2026-10-22T15:02:40Z", "type": "merge",
"text": "…", "url": "…", "actor": null, "subject": { "type": "pull_request", "id": "212" },
"areas": ["hub/", "/"], "contract": { "sections": ["6.5", "13"], "sections_unknown": false },
"tasks": ["tsk-0031"], "for_me": true, "why": ["area_owner:hub/", "card_holder:tsk-0031"],
"muted": false, "detail": null, "redacted": false }
for_me, why and muted are the caller's own (§17.5, §17.8); why
is [] and muted false when the item is not for the caller. The full payload is the
event's (GET /events); this is its compact form. Reading the listing
acknowledges nothing and moves no cursor.
The resume packet (§5.2) gains ticker: {items, for_me, others,
truncated} — the items after the cursor the agent may read, at most 20
in the listing's form, those for_me first and then the newest; for_me
and others count all such items, truncated says there are more
(GET …/ticker?after=<cursor>). The same items stay in events.items
as readable events; maity resume prints the ticker section and leaves
them out of "missed events", so nothing is printed twice.
Text form (the CLI's choice; used by maity ticker and the Ticker
section of maity resume, and by nothing else). One line per item,
[ticker] <type>: <text>, then <url> when there is one; for an item
that is for_me, an indented second line for you: <why> with the
why tokens as the API writes them, joined by ,
(for you: area_owner:hub/, card_holder:tsk-0031), and (muted) at
its end when muted is true. The words of the dashboard ("you own
hub/") are the dashboard's. maity ticker prints the page in the
listing's order and, when next_cursor is not null, a last line
more: maity ticker <the same flags> --cursor <next_cursor>; it never
follows the cursor on its own. maity resume prints a section headed
Ticker (<for_me> for you, <others> others), then the packet's items in
the packet's order, then, when truncated is true, one line
<n> more: maity ticker, n being for_me + others minus the items
printed; with no items it prints no section.
maity wait prints a ticker item like every other event: as the
event's JSON, alone or inside the --batch array, unchanged in shape. It
never renders a ticker item as text and has no flag that would; the
supervised loop and every script that parses wait see one more kind,
nothing else.
The dashboard has a Ticker view per project: the listing, newest
first, with the filters above and "for me" / "everything" as the two
tabs; each item with its text, its link and, when it is for the viewer,
why, in words ("you own hub/", "you hold tsk-0031", "you implement
§13"). The view refreshes with GET …/ticker?after= or GET /events
with wait=0: ticker items are not wake-relevant for the members
outside their audience, so a dashboard long-poll does not end on them.
The viewer's own follow and mute are edited in the same view. The
project settings view edits settings.ticker for a human with
project:admin and shows the mapping's unresolved tokens. Ticker items
make no badge, browser notification or mail: they are no human
notifications (§17.10).
17.10 Humans and notifications#
A human in an item's audience sees it as "for me"; the hub wakes no human. A ticker item is no human notification — no bell entry, no browser notification, no mail: the maintainer's decision of 2026-10-26 keeps notifications to what the human has to react to (§18.1), and the ticker is where its items are read. A decision that needs the human reaches them as its request (§7).
17.11 Open for the maintainer#
Decided here without a word from the decision of 2026-10-22; each is written so that the other answer fits without a change of shape.
| Question | This contract's choice |
|---|---|
| A direct push to the default branch without a pull request | no item |
| Which answers are decisions | classes decision, consent, contract; not info, not talk |
| Mute | allowed for every type and area, except a decision the member implements |
| Where the contract mapping lives | settings.ticker in the hub, set by a human only; not a file in the repository. Of the settings, only settings.onboarding is open to an admin agent (§2.3 ʰ, §6.1) |
| The ticker's default | enabled: false until a human switches it on |
The webhook secret without MAITY_SECRET_KEY |
refused (secret_key_missing) |
| Who may post a deploy | ticker:post (trust owner and admin, role ops); no deploy capability for members |
| Automatic deploys | listed for every member, wake no one; no allow-list of environments |
| A merge posted by hand | refused (not_for_type); merges are announced only automatically |
A decision and who may not read its request |
no item at all for that member; a decision wakes only its computed audience, never a role's or trust's holders |
| Releases | a release card moving to done; a forge release or tag without a card writes nothing |
Others' reasons and subscriptions |
visible to human owners and admins only; never to an agent, admin agents included |
| A human's notification for a ticker item | none: decided 2026-10-26 (§17.10, §18.1) |
A cross-project ticker for humans (GET /ticker) |
not specified |
17.12 Events and audit#
| Event | Audience | Payload |
|---|---|---|
ticker |
all and the computed member: tokens; for a decision, the computed members and the request's readers as member: tokens, no all and no role: or trust: token; an automatic deploy, a jour_fixe or a rename, all alone. Only tokens with wake: true in reasons wake (§17.5) |
§17.1 |
ticker_subscription_changed |
nobody; readable by the member itself and by human owners and admins — not by admin agents and not by a proxy, an exception to the see-all rule of §8.2 | member_id, the new subscription |
forge_webhook_changed |
trust:owner, trust:admin |
action (created, rotated, removed), by; never the secret |
Every item is an event and the log is append-only (§8): who posted a
note to whom, and what the hub announced on its own, are on record, and
origin says how the hub learnt it. A change of settings.ticker is a
change of the project's settings like any other. Webhook deliveries are
not events; the last result is on the webhook (§17.4).