Skip to content

feat(effects): built-in effect library and the custom-unit authoring contract - #184

Merged
FelineStateMachine merged 7 commits into
mainfrom
feat/141-effect-library
Jul 20, 2026
Merged

feat(effects): built-in effect library and the custom-unit authoring contract#184
FelineStateMachine merged 7 commits into
mainfrom
feat/141-effect-library

Conversation

@FelineStateMachine

Copy link
Copy Markdown
Owner

Closes #141. Grows the #139 effect machinery (s.effect, per-obligation journaling, at-least-once delivery) into a tiered standard library and formalizes the contract for authoring custom units. Single PR covering all six issue sections; the one deferred item is called out below.

1. The authoring contract (package/schema/effects.ts)

  • EffectContext gains op and writeCreatedAt, so a unit can see the operation and measure the saved→fate latency.
  • Failure severity. New PermanentEffectError / isPermanentEffectError: a handler throws it to retire the obligation immediately (a call the receiver will keep refusing) instead of re-arming until maxAttempts. An ordinary throw stays retryable. The ledger honors the distinction and both are counted in diagnostics.
  • Naming/registration rules are unchanged and now documented as contract: app-unique durable names, fail-fast on collision. Anonymous built-ins take their identity from declaration order (anonymousEffectName), documented with the same orphaning caveat as a rename.
  • requireMutationRuntime exposes the runtime seam so every built-in is on the public authoring surface — nothing private. s.log was reviewed against this and already qualified.

2. Built-in units (package/schema/effect-library.ts), tiered by risk

Observations.trace(label?) (saved→fate span in diagnostics, OpenTelemetry-shaped, no vendor coupling), s.debug() (dev-only timeline, records nothing in a PROD bundle). s.log stays in the core beside its runtime recorder.

Data-internals.notice(config) (durable, UI-agnostic message queue, idempotent by journal id — closes the "a rejected write flashed success" failure mode), s.mark(table, config) (patch the row when its fate resolves; absolute/convergent patch; skips a rolled-back insert), s.chain(verb, toInput) (issue a follow-up verb on synced, no saga API).

Externals.webhook(url, opts?) (POST row + fate with the journal id as Idempotency-Key; 4xx → permanent, 5xx/429/network → retryable; default 24-hour delivery window since receiver keys expire, null opts out).

3. Runtime

  • notice-queue.ts — durable queue (OPFS → localStorage → memory), idempotent enqueue, TTL + dismissal, subscribe; drops entries expired-while-away on load.
  • write-ledger.ts installs recordTrace/recordDebug/enqueueNotice/applyMark, owns the queue singleton, and exposes public listNotices/subscribeNotices/dismissNotice/dismissAllNotices.
  • diagnostics.tseffectTraces, effectDebugTimeline, activeNotices.

4. Preact

useNotices() hook + <Notices /> component render the durable queue as an ARIA live region; a toast stack is a userland wrapper over the same hook.

5. Docs

docs/effects.md — the library reference, the authoring contract, and a worked custom unit modeled on a built-in. Teaching-path link added to nouns-and-verbs.md (declare → observe → make fate data → compose → reach outside), plus README reference entries.

6. Tests

  • Per-unit: happy path, rejection path, and idempotency mechanism (trace/notice/mark/chain/webhook).
  • notice-queue: idempotent enqueue, TTL retirement, persisted reload dropping expired entries.
  • Ledger: a PermanentEffectError retires on the first failure without exhausting attempts (alongside the existing quarantine test).
  • A custom unit built purely against the public contract — both a contract test and the guide's worked example.

Verification

  • deno task check green end-to-end (fmt, lint, doc, doc:closure, links, all suites — 21 JSR entrypoints).
  • deno task build green; manifest updated for effect-library.ts, notice-queue.ts, Notices.tsx, use-notices.ts.
  • Reference app adds s.trace("task-added") to addTask (dogfood); starter mirror + snapshot regenerated.

Deferred (follow-up)

The reference app's hand-rolled task-notice (publishNotice/useTaskNotice) is exactly the pattern s.notice replaces, but migrating it is a focused island refactor with its own snapshot churn — better as a separate PR than mixed into the library landing.

…ns, typed enrollment errors, account-gate freshness

The template half of #175, following the runtime half in #180.

DeviceStatus names why nothing is syncing, not merely that it is not:
the one-line Data sync verdict distinguishes owner mismatch, store
refusals (no schema, rejected ticket), an unopenable sink record, and
the absence of any sync location, with owner-mismatch copy naming the
owning account when known. The no-sync hint now leads with ticket
enrollment; the compiled-in env-var path is the stated alternative.

TicketEnrollForm relays typed refusals verbatim — SyncEnrollmentError,
SyncOwnerError, and DataSinkError carry their own remediation, and the
generic paste-again line cannot fix an unprovisioned store or a foreign
sync owner.

AccountGate re-reads the session when the runtime is recreated, gains
the owner-mismatch branch offering exactly the two remediations (stop
sync releases the election, restore adopts the owner), and surfaces a
definite store problem in the backed-up state. Starter mirror and
snapshot regenerated.
…contract

Closes #141. Grows the #139 effect machinery into a tiered standard library
and formalizes the contract for authoring custom units.

Contract (effects.ts):
- EffectContext gains `op` and `writeCreatedAt` so units can see the operation
  and measure saved→fate latency.
- PermanentEffectError / isPermanentEffectError: a handler throws it to retire
  an obligation immediately (a hopeless failure) instead of re-arming until
  maxAttempts. The ledger honors the severity.
- requireMutationRuntime + anonymousEffectName expose the seams the library is
  built on, keeping every built-in on the public authoring surface.

Built-in units (schema/effect-library.ts), tiered by risk:
- Observation — s.trace (saved→fate span in diagnostics, OTLP-shaped, no vendor
  coupling), s.debug (dev-only timeline, stripped in PROD). s.log stays in the
  core beside the runtime recorder.
- Data-internal — s.notice (durable, UI-agnostic message queue, idempotent by
  journal id; closes "a rejected write flashed success"), s.mark (patch the row
  on fate, convergent, skips a rolled-back insert), s.chain (issue a follow-up
  verb on synced, no saga API).
- External — s.webhook (POST row+fate with journalId as Idempotency-Key; 4xx →
  permanent, 5xx/429/network → retryable; default 24h delivery window).

Runtime:
- notice-queue.ts: durable queue (OPFS→localStorage→memory), idempotent
  enqueue, TTL + dismissal, subscribe; activeNotices diagnostic.
- write-ledger.ts installs recordTrace/recordDebug/enqueueNotice/applyMark and
  owns the queue singleton; public listNotices/subscribeNotices/dismissNotice.
- diagnostics.ts: effectTraces, effectDebugTimeline, activeNotices.

Preact: useNotices hook + Notices component render the durable queue; a toast
stack is a userland wrapper over the same hook.

Docs: docs/effects.md (library reference + authoring contract + worked custom
unit), teaching-path link from nouns-and-verbs, README reference entries.

Tests: per-unit happy/rejection/idempotency, notice-queue idempotence/TTL/
reload, ledger permanent-failure retirement, and a custom unit built purely on
the public contract. Reference app adds s.trace to addTask (dogfood); starter
mirror + snapshot regenerated. Manifest updated for the three new modules.

Deferred, noted for a follow-up: migrating the reference app's hand-rolled
task-notice to s.notice (a focused island refactor).
…notice-queue boot merge, a11y

Addresses findings from the multi-agent + Codex review of #184.

Anonymous-unit identity (HIGH — silent mis-binding across reloads):
- notice/mark/chain no longer take their durable name from a global
  declaration counter (module-load-order dependent, so a re-armed
  obligation could bind to the WRONG unit — for chain, the wrong
  follow-up verb). They are now named `<verb>#<position>` at mutation()
  time, from the author-chosen verb and their slot in its effects — an
  identity independent of which module loads first.
- trace/debug/webhook are content-named and now share one unit per
  identity (cachedBuiltin), so reusing one across verbs aggregates
  instead of throwing a duplicate-name error. webhook keys on url+config.

Notice queue:
- Boot-window clobber (HIGH): load() now MERGES persisted entries with
  any enqueued during the async load window instead of overwriting, so a
  notice an effect enqueues at a boot re-arm is not silently dropped.
- TTL drift (MEDIUM): list() is now a pure read; retirement happens in
  sweep(), wired to a periodic timer in the queue owner; the activeNotices
  count is computed from the live view so it never overcounts expired
  entries.

mark best-effort (MEDIUM): applyMark swallows a denied/vanished-row patch
instead of quarantine-spamming; documented that mark is a convenience over
the status column, not a delivery guarantee (use notice/webhook for that).

Notices a11y (MEDIUM): the aria-live region stays mounted (hidden) when
empty so the first notice is announced; custom children are keyed.

Tests: anonymous-unit verb-scoped naming, content-name sharing across
verbs, notice boot-merge. All green; build refreshed.
@FelineStateMachine

Copy link
Copy Markdown
Owner Author

Review pass (multi-agent + Codex) — findings addressed

Ran a three-agent correctness review plus a Codex release-readiness read. The converging headline was the anonymous-unit durable identity, which Codex flagged as the release blocker. Fixes landed in 48a2820:

Finding Severity Resolution
Anonymous notice/mark/chain named by a global module-load counter → a re-armed obligation could bind to the wrong unit across reloads (wrong follow-up verb for chain) HIGH Named <verb>#<position> at mutation() time — identity independent of module load order; reordering effects within one verb is the only footgun, documented
s.trace/s.debug/s.webhook fixed names collide when reused across verbs (latent) Content-named units now share one instance (cachedBuiltin); webhook keys on url+config
Notice queue: entry enqueued during the async load() window is clobbered HIGH load() merges persisted + in-flight entries instead of overwriting
Notice queue: TTL sweep on list() never persisted/emitted → activeNotices drift MEDIUM list() is a pure read; retirement in sweep() on a periodic timer; count computed from the live view
mark patch denial / vanished row → quarantine spam LOW/MED applyMark best-effort (swallows); documented as a convenience, not a guarantee
Notices aria-live region absent until first notice → first announcement dropped MED Region stays mounted (hidden when empty); custom children keyed

Verified sound by the reviewers (no change): webhook 4xx/5xx/429 classification, expiresAfterMs: null opt-out, tableless placeholder, notice idempotency key, persistence ordering, useNotices subscription lifecycle.

New tests cover verb-scoped anonymous naming, content-name sharing across verbs, and the notice boot-merge. deno task check + deno task build green.

Codex release-readiness note (for whoever cuts 0.9.0)

Verdict was NO-GO immediately, GO after a short stabilization gate. The blocking item (anonymous identity) is now resolved. Remaining pre-tag recommendations, not gated on this PR: a consumer-style import test over every new public symbol; a durable-format registry documenting the effect-journal and notice-queue on-disk formats (version + corruption/forward-version behavior); and a changelog with a "Persistence" section. Tracking these separately.

Use proof-of-possession connect URLs for store-status checks during enrollment and boot. Migrate the demo overlay to durable notices and improve recovery/passkey diagnostics.
@FelineStateMachine
FelineStateMachine merged commit f639126 into main Jul 20, 2026
3 checks passed
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.

feat: built-in effect library — tiered standard units and the custom-unit contract

1 participant