Skip to content

feat(system): typed intent seam between mutators and listeners (ADR-049)#325

Merged
sethvoltz merged 2 commits into
mainfrom
seth/typed-mutator-intent-seam
Jun 26, 2026
Merged

feat(system): typed intent seam between mutators and listeners (ADR-049)#325
sethvoltz merged 2 commits into
mainfrom
seth/typed-mutator-intent-seam

Conversation

@sethvoltz

Copy link
Copy Markdown
Owner

What & why

Refines ADR-023 (row-as-intent) — it does not reopen it. The seam stays the Postgres row + NOTIFY; no intents table is introduced. The friction it fixes is narrower: the payload crossing the @friday/shared mutator ↔ services/daemon listener boundary was implicit and untyped — a bare status string re-spelled at five sites, plus a byte-identical hand-rolled content_json parse duplicated across two listeners. Nothing forced the two sides to agree (the documented sendUserMessage postcondition had already drifted from the code).

What changed

New packages/shared/src/sync/intents.ts (node-free; imported by both the client-safe sync surface and the daemon barrel):

  • INTENT_STATUS — the 11 transient status tokens, centralized. Values frozen (keyed by in-flight rows + DB CHECK + trigger predicate) → this moves the declaration, not the strings, so no migration.
  • buildUserMessageContent / parseUserMessageContent — the typed view over a user block's content_json. The duplicated JSON.parse+regex block is removed from dispatch-listener and resume-listener; a { ok, content } discriminator preserves each side's distinct behavior (dispatch forks-empty, resume bails-corrupt) with one parser.

Adoption: all 7 side-effect mutators + their listeners (dispatch, resume, abort, cancel, archive, scheduler, memory). Apps remain the documented next adopter; updateSettings is the documented whole-row-reconcile exception. Idempotency (status precondition + atomic-claim UPDATE … WHERE status=token) is untouched.

Tests (intents.test.ts + daemon-layer): build↔parse round-trip, token↔live-DB-CHECK membership, per-mutator token writes, and the dispatch-forks-empty vs resume-bails-corrupt divergence pinned at the layer it lives in.

Docs: add ADR-049; fix the ADR-023 catalog drift (sendUserMessage is pending → queued|complete, never the documented dispatched).

How it was built

Multi-agent (ultracode): a 10-agent inventory mapped every seam/token/CHECK/ADR constraint; a 9-agent find→verify adversarial review surfaced 2 real issues (a misleading docstring and a test that named the listener divergence but only tested the parser) — both fixed, including the daemon-layer branch tests.

Verification

  • Shared sync 192/192 · daemon unit 1272/1272 · shared + daemon builds clean · dashboard svelte-check clean · prettier clean.
  • Behavior-preserving by construction; the only failures locally are the documented environmental pg-provision tests (host DB on a divergent migration chain — CI authoritative; host row untouched).

🤖 Generated with Claude Code

https://claude.ai/code/session_01PD86tp8FdJ12DgDoMT6UaQ

Refine ADR-023's row-as-intent pattern by making the mutator↔listener
payload an explicit, shared, typed view both sides import — instead of a
bare status string re-spelled at five sites and a hand-rolled content_json
parse duplicated across two daemon listeners.

New `@friday/shared/sync/intents.ts` (node-free):
- INTENT_STATUS: the 11 transient status tokens, centralized. Values are
  frozen (keyed by in-flight rows + DB CHECK + trigger predicate), so this
  moves the declaration, not the strings — no migration.
- buildUserMessageContent / parseUserMessageContent: the typed view over a
  user block's content_json. The byte-identical JSON.parse+regex block is
  removed from dispatch-listener and resume-listener; a {ok,content}
  discriminator preserves each side's distinct behavior (dispatch forks
  empty, resume bails corrupt) with one parser.

All seven side-effect mutators and their listeners (dispatch, resume,
abort, cancel, archive, scheduler, memory) adopt the const. Apps remain
the documented next adopter; updateSettings is the documented whole-row-
reconcile exception. Idempotency (status precondition + atomic-claim
UPDATE...WHERE status=token) is untouched.

Contract tests (intents.test.ts): build↔parse round-trip, token↔live-DB-
CHECK membership, and per-mutator token writes. Daemon-layer tests pin the
dispatch-forks-empty vs resume-bails-corrupt divergence at the layer it
lives in (the jsonb null literal is the only prod-reachable ok:false input
given content_json is NOT NULL and rowFromDb re-stringifies).

Docs: add ADR-049; fix the ADR-023 catalog drift (sendUserMessage is
pending → queued|complete, never the documented `dispatched`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PD86tp8FdJ12DgDoMT6UaQ
@sethvoltz sethvoltz enabled auto-merge (squash) June 26, 2026 01:54
The 6 unit configs already set testTimeout: 20_000 for cold-start headroom
but left hookTimeout at the 10s default. A `beforeAll` that runs
`createTestDb` (scratch Postgres + full migrations) plus heavy dynamic
imports — e.g. api/evolve-dreaming.test.ts:167 — intermittently exceeds 10s
on a cold CI runner and fails the whole file ("Hook timed out in 10000ms"),
even though it runs in ~3s locally. Mirror the testTimeout mitigation on the
hook path across all six unit configs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PD86tp8FdJ12DgDoMT6UaQ
@sethvoltz sethvoltz merged commit a6c90f4 into main Jun 26, 2026
11 of 12 checks passed
@sethvoltz sethvoltz deleted the seth/typed-mutator-intent-seam branch June 26, 2026 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant