Skip to content

Design: delivered means delivered — one truth contract for the aggregator alert throat #3702

Closed
3 of 3 issues completed

Description

@dezren39

Summary

The alert throat (pm-stack/aggregator/src/alerts.ts + push.ts, landed 2026-11-01 in #3676 under the #1254 push design) is the one PM-stack system whose entire value is that Drew can believe it — and within hours of landing, three filings, one per trust boundary, showed its claims about itself going unchecked:

Full design doc: files/designs/code-aggregator-alert-throat-contract.md in the keep-the-four-repos-healthy goal workspace.

The contract (stated once, in the alerts.ts module header)

The alert throat's records and responses tell the truth and keep its secrets. A state transition is recorded only when the event it names happened (delivered / escalated require at least one accepted send; gone is not a send). Subscription key material (p256dh, auth) never leaves the server: store rows are internal, and any HTTP surface projects — the page's projection is the only read shape. Subscription preferences are validated as values, not shapes, and an invalid value falls back per-field to the default — never stored, never reinterpreted.

Recorded decisions

  1. The unused subscriptions GET is removed, not projected (aggregator(alerts): GET /api/push/subscriptions returns every subscription's p256dh + auth secret — unauthenticated, unused by any client, and the /alerts page for the same data redacts them #3693 allows either). No in-repo client calls it (push-client.js POST/DELETE only; /alerts is server-rendered; the engine reads in-process), and even a projected GET would enumerate every subscription endpoint — the input the unauthenticated DELETE needs. A future consumer re-adds a read returning the page's projected shape; the projection rule is the standing requirement for that day.
  2. Fixes stay on their own side of the store seam. listSubscriptions() has two consumers with opposite needs (engine: full rows, in-process; HTTP: no secrets, ever). aggregator(alerts): GET /api/push/subscriptions returns every subscription's p256dh + auth secret — unauthenticated, unused by any client, and the /alerts page for the same data redacts them #3693's fix removes the HTTP surface, never narrows the store read — projecting the store query would break deliverToAll.
  3. Quiet validation is per-field value validation at merge time (hour 00–23, minute 00–59, else that field's default) — the parser's existing reject-to-default contract extended to digit-shaped input. Already-stored out-of-range rows are not silently rewritten; their disposition is a required output of Phase 3.

Phased breakdown

Open questions stay in the design doc (an aggregator-wide auth model; digest-class state following Rule 1; delivery backoff/dead-letter on operational evidence; sends-ledger retention).

Related: #3676 (the throat build), #1254 (push design), #3317 / #3319 (consumers of quiet + delivery truth), #1260 (pm-stack SLOs — the layer above, not this contract). Not this design: #3681 (chargesAttention shape — #3068/#3161's module), the feed gates (#2576's family).

🤖 Design architect pass 32 (2026-11-01), audit at origin/main a3acf3b0.

Acceptance criteria

Activity

  1. added
    epicMeta-issue grouping multiple items (org Project Type=Epic)
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    kind/designA design proposal (design-architect): multi-step, phased, decision-bearing
    on Nov 1, 2026
  2. developing-today commented on Nov 1, 2026

    @developing-today
    Contributor
  3. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · design filed

  4. added
    priority/normalOrdinary priority (the default)
    effort/lLarge: multi-day or open-ended; expect staged check-ins
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    on Nov 1, 2026
  5. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    Intake review

    Fleet monitor intake pass (protocol §5), 2026-11-01.

    Related: code#3685 / PR #3690 (Rule 1, landed), code#3693 (subscription secrets exposure), code#3676 / #1254 (the throat's origin design).

    Notes: Design epic filed by design-architect with kind/design + app/aggregator but no priority, effort, or acceptance criteria until this pass. It is a design, so the criteria above govern the design's completeness and hand-off, not code.

    Risks: Designs without acceptance criteria drift; the §11 risk is a finished design with no implementation issues filed.

    Suggested approach: Complete the audit phases, then file/link the implementation issues natively (sub-issues) in the same pass the design is declared done.

  6. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    Phase 3 (closing audit) — contract statement, seam check, dispositions

    🤖 Agent note · phase complete

    Gate (revalidated fresh 2026-11-03): #3693 CLOSED (PR #4413), #3686 CLOSED (PR #4786) → Phase 3 unblocked. #3703 is the audit issue; contract statement is PR #4977.

    (a) The contract statement is in the code

    pm-stack/aggregator/src/alerts.ts, module header beside the #3676 design notes (PR #4977, comment-only):

    The alert throat's records and responses tell the truth and keep its secrets. A state transition is recorded only when the event it names happened (delivered / escalated require at least one accepted send; gone is not a send). Subscription key material (p256dh, auth) never leaves the server: store rows are internal, any HTTP surface projects — the page's projection is the only read shape. Subscription preferences are validated as values, not shapes, and an invalid value falls back per-field to the default — never stored, never reinterpreted.

    (b) Seam check — routes and fields reviewed

    Route (src/server.ts) Returns Store rows verbatim?
    GET /api/push/subscriptions (1616–1621) .map(toPublicSubscription) — id, endpoint, classes, quiet, user_agent, created_at, last_seen_at, gone_count No
    POST /api/push/subscriptions (1622–1626) projected subscription No
    DELETE /api/push/subscriptions (1627–1636) {deleted} / {error} n/a (write)
    POST /api/push/test (1643–1649) send summary (counts/errors) No
    POST /api/alerts (1652–1663) {incident, created} — Bearer intake token (alertsIntakeAuthorized, 322–331) No secrets: id, source, incident_key, severity, title, body, status, timestamps, occurrences
    POST /api/alerts/:id/(ack|close) (1665–1674) {incident, transitioned} Same incident fields, no secrets
    GET /alerts (1675–1702) server-rendered page Renders subscription host, classes, quiet, UA; embeds endpoint as the unsubscribe key (data-unsub + SUBS script constant). p256dh/auth never rendered.
    GET /api/alerts.json (1694) {incidents, events} verbatim Incident/event fields only — no secrets today, no subscription rows

    AlertsStore.listSubscriptions() (alerts.ts:330) still returns full rows and only the in-process engine (runAlertTick, 604+) and the server-rendered page consume them; delivery is untouched. Verdict: no HTTP route returns a store row verbatim.

    One divergence from record: decision 1 said the unused subscriptions GET is removed; #3693 as landed projected it. The secrecy clause holds, but the projected GET still enumerates every endpoint — the input the unauthenticated DELETE needs. Filed as #4983 (remove it; no in-repo client uses it).

    (c) The four dispositions, with reasons

    1. Stored out-of-range quiet rows — not rewritten (decision 3). New rows cannot be bad after aggregator(alerts): mergeQuiet accepts out-of-range times (99:99, 24:60) — a malformed quiet window is silently reinterpreted instead of falling back to the default #3686, but inQuietHours (alerts.ts:168) still toMin()s the stored string, so a pre-aggregator(alerts): mergeQuiet accepts out-of-range times (99:99, 24:60) — a malformed quiet window is silently reinterpreted instead of falling back to the default #3686 99:99/24:60 silently becomes a different window. Disposition: no silent rewrite; fix at read time (sanitize/fallback) and/or one-off data fix with row ids recorded → aggregator(alerts): sanitize stored out-of-range quiet prefs at read time (#3702 disposition 1) #4978. Reason: rewriting hides that the value was bad, and the design forbids silent reinterpretation — but reading it raw is reinterpretation.
    2. Unauthenticated DELETE /api/push/subscriptions + ack/close posture — recorded exposure: anyone with an endpoint can unsubscribe it (and the page hands endpoints out); anyone can ack/close an incident, suppressing a critical escalation; anyone can fan out POST /api/push/test. Disposition: accepted as the aggregator-wide stance for v1 — no partial auth on one route while the page that supplies the inputs is public, and auth on DELETE alone would break push-client.js; the mitigation is the private origin. The shared posture is aggregator(alerts): aggregator-wide auth/CSRF posture for mutating routes (#3702 disposition 2) #4980. Reason: a one-route fix would be security theatre, not a boundary.
    3. mergeQuiet tz acceptance (fails open on unknown zones) — mergeQuiet validates the zone by constructing Intl.DateTimeFormat and falls back to America/Chicago (alerts.ts:141–158, test at test/alerts.test.ts:102); inQuietHours catches an unknown stored zone and returns false = no suppression (alerts.ts:168–186). Disposition: accepted — delivery failing open is the deliberate direction (silently suppressing is the worse failure, and the contract is about not claiming things), and no new invalid zone can be stored. Stored bad zones are covered by aggregator(alerts): sanitize stored out-of-range quiet prefs at read time (#3702 disposition 1) #4978 if we want default-fallback instead.
    4. mergeClasses unbounded keys — copies every boolean key (alerts.ts:119), so a client can grow classes without bound; unknown keys are semantically inert (classEnabled = on unless explicit false; pass-through pinned at test/alerts.test.ts:53). Disposition: accept for v1 — no server-side amplification, one row per subscription, and feat(aggregator): notification engine — class triggers, dedupe, quiet hours, escalation (#1254 slice c) #3317 is about to introduce real classes, so a strict allowlist now would drop them. Bound it once a class registry exists → aggregator(alerts): bound mergeClasses keys to a class registry (#3702 disposition 4) #4981.

    (d) Closing evidence

    Phase 3 is complete with PR #4977 merged; #3703 closes with it. Follow-ups from (c)/(b): #4978, #4980, #4981, #4983.

  7. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · design complete — closing

    All three phases have landed and the Phase 3 audit is recorded above:

    Acceptance criteria met: the contract is named in the code, every phase had its own implementation issue, and Rule 1 is cited as-built (#3685/PR #3690) rather than re-adopted.

    Carried forward as named issues, not comments-only: #4978 (stored out-of-range quiet rows), #4980 (aggregator-wide auth/CSRF posture), #4981 (bound mergeClasses keys), #4983 (remove the now-projected GET). Open questions remain in the design doc (files/designs/code-aggregator-alert-throat-contract.md).

  8. added
    queue/queuedHas a queued task in the Supabase agent queue
    task/triageQueue packet kind: verify, reproduce, and route this item
    and removed on Nov 3, 2026
  9. developing-today commented on Nov 3, 2026

    @developing-today
    Contributor

    agent-note:v1
    state: triaged (coverage pass)
    Coverage pass 2026-11-03: reviewed in the fleet-wide per-issue pass (drift / overlap / already-handled / stale / blockers / ordering). Verdict: still live; queued in repo-health-code-pilot as triage at priority 40 (priority/normal). If this is already handled at current main, the triage closes it with evidence; if it needs splitting, the triage files the split.
    Found via: coverage pass 2026-11-03

  10. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    #4978 production-row disposition (2026-11-03)

    A read-only query of the live muse-dev2 alerts.push_subscriptions table found 1 subscription and no rows with invalid quiet.start/quiet.end clock values or an unrecognized quiet.tz. No stored rows were rewritten. This fix applies mergeQuiet() to rows returned by listSubscriptions(), so any legacy bad preference is defaulted in memory on read while the stored value remains unchanged, as required by decision 3.

  11. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    #4978 landed

    The read-side sanitize for stored quiet preferences is merged: PR #5169 → d153d51b8.

    • AlertsStore.listSubscriptions() now runs each stored quiet through mergeQuiet(), so a legacy out-of-range start/end or unknown tz is defaulted in memory per field; the stored row is never rewritten (decision 3).
    • New regression test covers 99:99, 24:60, an unknown zone, valid preferences, and the no-write path (pm-stack/aggregator/test/alerts.test.ts).
    • Production rows: the 2026-11-03 read-only check found 1 subscription on muse-dev2 and no invalid quiet values; nothing was rewritten.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/aggregatorPackage/app: aggregator (pm-stack in this repo)app/pm-stackpm-stack (Pad/Grist) in developing-today/codeeffort/lLarge: multi-day or open-ended; expect staged check-insepicMeta-issue grouping multiple items (org Project Type=Epic)kind/designA design proposal (design-architect): multi-step, phased, decision-bearingmeta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/normalOrdinary priority (the default)queue/queuedHas a queued task in the Supabase agent queuesource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions