Skip to content

feat(notify,core,cli): the two notify tables get a sweep, and the inbox window is the app's to name - #383

Merged
sebyx07 merged 2 commits into
mainfrom
fix/the-one-framework-table-with-no-sweep
Aug 27, 2026
Merged

feat(notify,core,cli): the two notify tables get a sweep, and the inbox window is the app's to name#383
sebyx07 merged 2 commits into
mainfrom
fix/the-one-framework-table-with-no-sweep

Conversation

@sebyx07

@sebyx07 sebyx07 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes #363.

Two premises in the issue were false, and they changed the shape of the work

Checked before starting; both recorded on the issue.

  1. x_notify_deliveries was NOT swept. The issue's §"What was decided" said it shipped as a 4th PurgeTarget. retentionTargets returned exactly three — x_idempotency, x_rate_limit, x_auth — and dev-purge.ts's own header said "the three framework tables". So both notify tables were unswept.
  2. The boot does not install the Postgres notify stores. Neither createPgDeliveryLedger nor createPgInboxStore had a caller outside packages/notify/.

The second is not a holeframework-schema.ts:100 says so deliberately: the DDL is applied by every boot "whether or not this boot calls setNotifyStores, for the same reason as every row above it — that call is an APP's boot line and runs after this one." Which is what fixes the design of the sweep: it cannot hold the stores, only ask what is installed now.

Why the inbox needed a config key and the others did not

Every other framework table holds bookkeeping whose job ends — an idempotency key, a rate-limit bucket, an auth challenge, a delivery claim. A sweep is unambiguously right. An inbox row is a message a person has not read yet, so when it disappears is a product decision, not a framework one. Axiom 8.

key default
notify.inboxReadRetentionMs absent — never swept
notify.inboxUnreadRetentionMs absent — never swept

Two windows, not one, because the axiom-8 objection is specifically about unread messages. Read notices gone in a month with unread ones kept forever is what an app actually wants, and it is only expressible if the two are separate:

notify: { inboxReadRetentionMs: 30 * 24 * 60 * 60 * 1000 }

A read row ages from read_at, an unread one from created_at — ageing a read row from created_at would delete a notification the moment the recipient opened an old one. One statement, each half inert when its cutoff is null.

Milliseconds and not a DurationInput: that type is @ultimat3/jobs (tier 3) and AppConfig is tier 0 — the same reason cache.defaultTtlMs is spelled this way. Zero is refused rather than read as "immediately", because a sweep at age 0 is an inbox that silently receives nothing.

Both halves in one change, so neither key is declared-and-never-wired

bun run scripts/config-readers.ts29 leaf keys, every one read. The reader is dev-notify-retention.ts, the sibling of loadSignInPath and loadCacheTiers; startServices holds no AppConfig, which is the same reason configureAuthLimiters takes a factory. DevServices gains root for it, carried rather than re-derived from stateDir — a dirname that silently disagreed with join(root, '.x') is a path bug nothing would catch.

The purge lives on the Postgres stores' own types

PgInboxStore.purgeBefore and PgDeliveryLedger.purgeExpired, never on InboxStore/DeliveryLedger. Adding a method to the seam every implementation must satisfy is a breaking change for an app that wrote its own, and a heap map bounded by process life has nothing to delete. PostgresIdempotencyStore already has exactly this shape, windowMs included.

retention.ts reads the seam per attempt and answers 0 for a memory store or none at all — the same shape as purgeAuthLimits(), and the reason authTarget is not in RetentionStores either. A typeof store.purgeBefore === 'function' check over a declared widening, not duck typing.

Both statements carry returning

A bare delete answers no rows through PgExecutor, so a count read off it is zero forever while the delete succeeds — a sweep that reads in a log exactly like one that is not wired at all. SQL_IDEMPOTENCY_PURGE is spliced the same way for the same reason.

The delivery window is not a config key

It is createPgDeliveryLedger({ executor, windowMs }), stated beside the statement that reads it, default 24 h. Never shorter than the app's idempotency window — a job replayed inside that window against a claim already purged claims cleanly and sends the notification a second time. Passing idempotency.windowMs makes that impossible by construction rather than by two defaults that happen to agree.

Tests

  • retention.test.ts — all four states the seam can be found in. Mutation-proven: relaxing the capability check to always-true turns 3 of 6 red.
  • inbox-pg.test.ts — each half of the statement reads the column its window is about; returning id present; an absent window binds null; neither window set issues no statement at all (the default state of every app, on an hourly sweep).
  • ledger-pg.test.ts — the cutoff is the caller's clock minus the ledger's own window; the window is readable so a caller can copy it; a non-positive window is refused at construction.
  • dev-purge.test.ts — the target list asserted by name, never counted: x_notify_inbox became the unswept table by being absent from a list nothing asserted, and a length check cannot say which one went missing. Plus each window arriving as its own cutoff, and an unset window arriving absent rather than as a NaN date every row is older than.
  • dev-notify-retention.test.ts — a real app.config.ts on disk, including the four shapes that must read as "never swept".
  • config.test.ts — the screened-key list asserted against NotifyConfig's own keys, so a third window with no screen is a red test.

Docs corrected

packages/notify/CLAUDE.md claimed the boot "does not install x_notify_deliveries or x_notify_inbox" — true when written, false since. Replaced with the retention table. wiki/Configuration.md gains the notify section; packages/notify/README.md gains a Retention section and its setNotifyStores example now passes windowMs.

Verified

  • bun run verify — 14 of 20 passed, 6 skipped (drift, contract-diff, budgets, seo, i18n, policy), the documented root baseline
  • bun run scripts/reference-app-gate.ts — every pin holds: examples/dummy 18/20 (2 pinned), dummy/social-media-clone 18/20 (2 pinned)

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…ox window is the app's to name

`x_notify_inbox` was the one framework table nothing swept, and
`x_notify_deliveries` was the second — the issue said the ledger already
had a target and `retentionTargets` returned exactly three. Both tables'
DDL is applied by every boot (`framework-schema.ts`), so both grew
without bound in every deployment the framework produces.

WHY THE INBOX NEEDED A CONFIG KEY AND THE OTHERS DID NOT. Every other
framework table holds bookkeeping whose job ENDS — an idempotency key, a
rate-limit bucket, an auth challenge, a delivery claim. An inbox row is a
message a person has not read yet, so when it disappears is a product
decision, which is axiom 8. `notify.inboxReadRetentionMs` and
`notify.inboxUnreadRetentionMs` are therefore the app's, **both absent by
default**, and absent means never swept.

Two windows rather than one because the objection is only about UNREAD
messages: read notices gone in a month with unread ones kept forever is
what an app actually wants, and it is only expressible if the two are
separate. A read row ages from `read_at` and an unread one from
`created_at` — ageing a read row from `created_at` would delete a
notification the moment the recipient opened an old one.

THE SEAM IS READ PER ATTEMPT, NOT HANDED OVER. `setNotifyStores` is an
app's boot line and runs when the app's modules import, which is AFTER
`installRetentionSweep`. So `retention.ts` asks `notifyStores()` on each
sweep and answers 0 for a memory store or none at all — the same shape as
`purgeAuthLimits()`, and the reason `authTarget` is not in
`RetentionStores` either.

`purgeBefore` and `purgeExpired` are on the Postgres stores' own wider
types (`PgInboxStore`, `PgDeliveryLedger`), never on the seam every
implementation must satisfy: adding a method there breaks an app that
wrote its own, and a heap map bounded by process life has nothing to
delete. `PostgresIdempotencyStore` already has exactly this shape.

Both statements carry `returning`. A bare `delete` answers no rows
through `PgExecutor`, so the count would be zero forever while the delete
succeeded — a sweep that reads in a log exactly like one that is not
wired at all.

`DevServices` gains `root`: `loadInboxRetention` reads the app's own
`app.config.ts` and `startServices` holds no `AppConfig`, which is the
same reason `configureAuthLimiters` takes a factory.

Fixes #363

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The live suite asserted the three-target list by name, so adding two
targets turned it red in CI — which is the assertion working. Updated,
and extended: `dev-purge.test.ts` proves the wiring with stubs and can
prove nothing about the SQL, and a purge whose statement never executed
is exactly the state both notify tables shipped in.

The windows travel the REAL path — `boot()` writes an `app.config.ts`
into the scratch root and `loadInboxRetention` reads it — because
handing them to `installRetentionSweep` directly would prove the sweep
and not the wiring, and the wiring is the half a config key loses.

One read row deleted and the old UNREAD row kept, because no unread
window was configured: the default, and the axiom-8 promise this key
exists to keep.

`read_at` is stamped with SQL rather than through `inbox.markRead`. That
call binds a uuid ARRAY and Bun does not encode a JS array as a Postgres
array parameter at all — issue #384, found by this test, which also takes
down `SQL_CLAIM` (the worker's claim loop) and `SQL_OUTBOX_RELEASE`.
Fixed in its own change; using `markRead` here would make a retention
test fail for a reason that is not retention.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 25 days. After that, they cost $0.25 per reviewed file.

Or wait 17 seconds for your next included review.

View limit details

Limit details: You’ve used the included review currently available. Your 75 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2658e90b-516e-45cc-82b4-393882d4ce95

📥 Commits

Reviewing files that changed from the base of the PR and between 70c3787 and 49ff36a.

📒 Files selected for processing (25)
  • packages/cli/CLAUDE.md
  • packages/cli/src/dev-dashboard.test.ts
  • packages/cli/src/dev-notify-retention.test.ts
  • packages/cli/src/dev-notify-retention.ts
  • packages/cli/src/dev-purge.live.test.ts
  • packages/cli/src/dev-purge.test.ts
  • packages/cli/src/dev-purge.ts
  • packages/cli/src/dev-replicator.test.ts
  • packages/cli/src/dev-runtime.ts
  • packages/cli/src/dev-services.test.ts
  • packages/cli/src/dev-services.ts
  • packages/cli/src/mcp-host.test.ts
  • packages/core/src/config.test.ts
  • packages/core/src/config.ts
  • packages/core/src/index.ts
  • packages/notify/CLAUDE.md
  • packages/notify/README.md
  • packages/notify/src/inbox-pg.test.ts
  • packages/notify/src/inbox-pg.ts
  • packages/notify/src/index.ts
  • packages/notify/src/ledger-pg.test.ts
  • packages/notify/src/ledger-pg.ts
  • packages/notify/src/retention.test.ts
  • packages/notify/src/retention.ts
  • wiki/Configuration.md

Comment @coderabbitai help to get the list of available commands.

@sebyx07

sebyx07 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

This PR's live test found a critical pre-existing defect, filed as #384.

inbox.markRead binds id = any($2::uuid[]), and Bun does not encode a JS array as a Postgres array parameter — it joins with commas, so Postgres answers malformed array literal (22P02). Measured on Bun 1.4.0 against real Postgres 17.

Three shipped statements bind an array through that path, and all three fail:

Site What stops working
SQL_CLAIM (jobs/driver-pg-sql.ts:67) the worker's claim loopcreatePgDriver is what every ROLE=worker container runs
SQL_OUTBOX_RELEASE (:359) the relay releasing a batch it could not publish
SQL_NOTIFY_INBOX_MARK_READ marking an in-app notification read

Why nothing caught it: grep -rln '\.claim(' --include=*.live.test.ts packages/ answers one file, and it is the notify live test added here. Every other test of these statements runs against a recording executor, so the SQL is asserted as text and never executed.

It is fixed in its own change, not here. This PR is about retention; #384 is about parameter encoding across two packages, and folding a critical fix into a feature PR makes both harder to review and to revert. The live test stamps read_at with SQL and carries a comment naming #384 so nobody 'simplifies' it back to markRead before that lands.

The updated live suite passes against real Postgres 17 — 4 of 4, with the notify sweep really removing rows (1 delivery, 1 inbox), which is what proves the statements execute at all.

@sebyx07

sebyx07 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@developerz-ai

developerz-ai Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Hi @sebyx07 - I see you requested a review from @coderabbitai. I'm the maintainer automation bot (developerz.ai[bot]), not CodeRabbit. I'll let CodeRabbit's review proceed.

Once CI is green and reviews are in, feel free to tag me for any follow-up triage needs.

🤖 Posted by developerz.ai — the maintainer agent, not a human.

@developerz-ai

developerz-ai Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Looks good — CI green, no risk signals, and this is a routine feature addition. Ready for maintainer merge when ready.

🤖 Posted by developerz.ai — the maintainer agent, not a human.

@sebyx07
sebyx07 merged commit 7174490 into main Aug 27, 2026
38 checks passed
@sebyx07
sebyx07 deleted the fix/the-one-framework-table-with-no-sweep branch August 27, 2026 02:45
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.

notify: x_notify_inbox is the one framework table with no retention sweep, and naming a window needs an AppConfig key

1 participant