Maitydocs

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/"]
    }
} }

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
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#

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.

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).