Repository navigation
tenant-audit, the update half: ack is a dispatcher sweep but redeliver is request-reachable — two sites on one object with OPPOSITE classifications #10740
Description
Activity
- addedbugSomething isn't workingSomething isn't workingand removed
on Aug 21, 2026 huangyiirene commented
on Aug 21, 2026 CollaboratorMore actionsTriage (finding round): promoted →
pm:queue,domain:services(service-messaging + plugin-webhooks), type Bug. The two classifications are the dispatch clauses, verbatim from the card:ack× 2 inherit #10673's documented structural sweep warrant;redeliverthreads the request's tenant and must NEVER getbypassTenantAudit— it is request-reachable by any authenticated user, so on an isolated posture it is exactly the unscoped write the audit exists to catch. Threading the tenant narrows accepted writes back to the declared tenancy invariant (not a product decision), but it changesIHttpOutbox.redeliver's signature: claim withClause-②: yesand the contract-review tier. Re-measure by driving a real delivery through, not by reading a boot log — the card's own warning.
Generated by Claude Code
Claim: dispatched.
- session: user-29 [974948]
- branch:
claude/issue-10740-tenant-audit-update-half - worktree:
../objectstack-10740 - Clause-②: YES —
redeliver's remedy changesIHttpOutbox.redeliver's signature. Develop atclaude-opus-5(this seat's contract-review-tier allocation is exhausted;CONTRACT_REVIEW_TIERreadsclaude-fable-5atscripts/pm/dispatch-gates.mjs:1932). PR stays draft;needs:contract-reviewhung as the compensating control. ⛔ This seat does not clear it. - serial constraint:
packages/services/service-messaging(sql-http-outbox.ts,sql-outbox.ts) andoutbox-dispatcher-scope.ts. Free.
Why this is dispatchable when it says "needs a contract decision"
Because the decision is already made against the wrong answer, and the card makes it:
⛔ It must not be given
bypassTenantAuditto make the line go away — the audit's whole purpose is to force this classification, and this is the site it was built for.What remains is implementing the named remedy — thread the request's tenant — at the tier that change earns. That is work, not a ruling. ⛔ If the implementation turns up a reason the tenant cannot be threaded, that is a
needs_decisionand it comes back; it is not licence to fall back to a bypass.⛔ The one mistake that must not be made
The three sites are on one object and their classifications are opposite. Treating this as one sweep is the dangerous error:
site reachable from classification SqlNotificationOutbox.ackdispatcher tick only global sweep — inherits #10673's structural warrant SqlHttpOutbox.ackdispatcher tick only global sweep — same warrant SqlHttpOutbox.redeliverPOST /api/v1/webhooks/redeliver⛔ request-contextual plugin-webhooks' own comment on that route says any authenticated user can reach it. UnderOS_TENANCY_POSTURE=isolatedthat is an unscoped write reachable by an authenticated user — the exact case the tenant audit exists to catch, and the one case where silencing the line converts a detectable hole into an undetectable one.The two
acksites earn their warrant structurally, not by judgement: their only callers are the dispatchers'setIntervaltick under a cluster lock;ClaimOptions/HttpClaimOptionscarry no tenant even in principle; partitioning ishash(...) mod N— a load-spreading key, not an org key, so a partition holds every org's rows by construction; and one outbox per environment drains the whole queue, so a per-org predicate would strand every other org's deliveries and leave a crashed node's rows permanentlyin_flight. Re-derive that warrant rather than inheriting it — if any limb of it no longer holds, the classification changes.⚠️ outbox-dispatcher-scope.ts(added by PR #10725) already holds the reasoning, but its return type is& { multi: true }, so it cannot be reused here as-is — and that is deliberate. These aremulti: falsesites under theupdateop, a different op from theupdateManyone #10673 covered.⚠️ Do not re-measure this from a boot log#10673's card quotes a composed-boot startup log showing exactly two
[tenant-audit]lines. That is not evidence theupdateop is clean, and the card says why:The reap
UPDATEruns unconditionally on every dispatcher tick, whileack/redeliveronly run once a delivery is actually processed. A startup log taken on an empty queue therefore shows exactly the twoupdateManylines and says nothing at all about theupdate-op surface.Drive an actual delivery through. An audit line that does not appear because nothing ran is NOT MEASURED, not a pass — the same class as a gate that refuses.
Pins — both directions
redelivercarries the caller's tenant, and the audit line for that site disappears because the write is scoped, not because it was silenced. Prove the distinction: a scoped write and a bypassed write both produce no audit line, so an assertion that only checks for the line's absence cannot tell them apart. Assert the predicate that reached the driver.- the two
acksites still audit-clean under their warrant, and a delivery actually flows. - a cross-tenant
redeliverattempt is refused, with ADR-0112codeANDstatus. - still-works: an in-tenant redeliver still succeeds. An implementation that refuses every redeliver would pass a one-directional suite.
Standing proof standard
Ablation with the signature predicted first; restore proved byte-identical by
git hash-object. Provesrc/vsdist/resolution argued from the files. Zero-hit counter-check with a positive control on the same corpus — and note that a non-zero from a term that pre-dates your change proves nothing either. Gate union derived on the final commit, clean tree,dispatch-gates.mjswith no path arguments, exit codes captured before any pipe. Class #10309 is live — run what you judge relevant explicitly and say whether the derivation named it. Changeset required. Nevergit stash(shared stack). Never report a grep hit count as a fact count.⛔ Do not touch
content/docs/releases/**. ⛔ Zeropackages/specownership in this lane — if the contract change needs a spec edit, stop and report; it carves out like #10999 did.⚠️ Not in scope: cloud#1512'scrm_contracthalf. Do not reach for it.Refs
#10673 / PR #10725 (the
updateManyhalf and the structural warrant) ·sql-http-outbox.ts,sql-outbox.ts·sql-driver.tsauditMissingTenant(the instrument) · cloud#1512 (upstream)
Generated by Claude Code
os-dev-report
{ "issue": 10740, "status": "done", "branch": "claude/issue-10740-tenant-audit-update-half", "pr": "https://github.com/objectstack-ai/objectstack/pull/11010", "premise_still_valid": true, "summary": "All three update-op sites discharged under their OWN classifications. The two ack sites (SqlNotificationOutbox.ack, SqlHttpOutbox.ack) are declared global sweeps through a new dispatcherAckOptions() helper — a multi:false sibling of dispatcherSweepOptions, not a reuse. SqlHttpOutbox.redeliver is request-contextual and now threads the caller's tenant to the rows it READS as well as the row it writes; it carries no bypassTenantAudit anywhere on the path, and the webhook route resolves the session's activeOrganizationId to supply the value. IHttpOutbox.redeliver(id, options) and MessagingService.redeliverHttp(id, options) take a REQUIRED tenantId typed `string | undefined`, so omitting the decision does not compile. Zero packages/spec edits were needed: tenantId is already a declared engine passthrough key. PR is draft; needs:contract-review left untouched on the card.", "classifications": { "SqlNotificationOutbox.ack": { "verdict": "global sweep — bypassTenantAudit, warrant re-derived on this tree", "evidence": "All four limbs re-checked, none inherited. (1) ack has exactly two callers, dispatcher.ts:210,240,249,286 and http-dispatcher.ts:183,193, all inside runPartition() driven by setInterval under cluster.lock.acquire('notify.dispatcher.partition.N' / 'http.dispatcher.partition.N') — verified by grepping every `.ack(` call in non-test src across packages/, apps/, examples/. (2) ClaimOptions (outbox.ts:79-88) is exactly {nodeId, limit, partition, claimTtlMs, now} — no tenant even in principle. (3) hashPartition (backoff.ts:11) is 32-bit FNV-1a over the key, mod count — load-spreading, not an org key. (4) messaging-service-plugin.ts:282,310 constructs one outbox + one dispatcher pair per ENVIRONMENT. Measured behaviourally too: one dispatcher tick acked rows for org_a AND org_b in the same run." }, "SqlHttpOutbox.ack": { "verdict": "global sweep — same warrant, same helper", "evidence": "Same four limbs (HttpClaimOptions at http-outbox.ts:219-234 is the same five-field shape). Behaviourally: one HttpDispatcher tick POSTed and acked both organizations' rows, both ending success/attempts=1, both driver.update calls carrying bypassTenantAudit:true and tenantId undefined." }, "SqlHttpOutbox.redeliver": { "verdict": "request-contextual — threads the caller's tenant, NO bypass", "evidence": "POST /api/v1/webhooks/redeliver (webhook-outbox-plugin.ts:363) gates on a better-auth session user id only — the route's own docstring says every authenticated user counts. resolveTenantField('sys_http_delivery') answers organization_id under OS_TENANCY_POSTURE=isolated, asserted as a precondition in every test. Remedy applied: options.tenantId threaded to both findOne reads and the update, so a foreign row is invisible (RESOURCE_NOT_FOUND / 404) rather than forbidden." } }, "scoped_vs_bypassed_proof": "The pin the card asked for, because a scoped write and a bypassed write produce IDENTICAL silence in the log. Instead of asserting the audit line's absence, the test installs a spy on SqlDriver.update — the single method that both calls auditMissingTenant(object,'update',options) and applies applyTenantScope — and asserts the options that actually reached the driver: `expect(writes[0].options?.tenantId).toBe('org_a')` AND `expect(writes[0].options?.bypassTenantAudit).toBeUndefined()`. Recording at the DRIVER rather than the engine is deliberate: buildDriverOptions can add or withhold tenantId, so the engine-side argument is not a reading of what the write was scoped by. A zero-hit counter-check backs it: across the five redeliver-path files, `bypassTenantAudit` appears on 5 lines and ALL FIVE are doc-comment prohibitions — zero code lines, with the same grep pipeline returning 4 real code lines for the positive control (dispatcherAckOptions / options.tenantId / tenantId,). Additionally the 'tenant-less caller is NOT silenced' test asserts the audit line DOES fire when tenantId is undefined, so a future bypass on this path turns that test red rather than green.", "ablation": { "predicted_first": "Written to disk before any mutation. Mutation: drop the threaded tenant from redeliver's WRITE leg only, leaving the reads scoped. Predicted (1) tenantId assertion RED, undefined vs 'org_a'; (2) the [tenant-audit] update line APPEARS — direction is a diagnostic ADDED, not removed; (3) cross-tenant refusal STAYS GREEN, since the refusal comes from the untouched read leg — the whole point of the pin; (4) still-works STAYS GREEN; (5) both ack tests STAY GREEN.", "observed": "(1) RED exactly as predicted — `AssertionError: expected undefined to be 'org_a'` at line 273. (2) NOT OBSERVABLE in this run, reported rather than claimed: vitest aborts a test at its first failing assertion, which is (1), so the audit-line assertion later in the same test never evaluated. The direction is confirmed by a different, always-green assertion — the 'tenant-less caller is NOT silenced' test, which pins that an unscoped redeliver write DOES produce the line. (3) GREEN. (4) GREEN. (5) GREEN. Totals: 1 failed / 5 passed, then 6/6 on restore.", "restore_hashes": "git hash-object packages/services/service-messaging/src/sql-http-outbox.ts — pre-ablation 41c5a0b96e6d06a39f6f3eb59f3f896039a2d903, post-restore 41c5a0b96e6d06a39f6f3eb59f3f896039a2d903 (identical), and `git status --short` on that path prints nothing.", "rebuild": "NO rebuild of the subject was needed or performed, and that is the finding rather than an omission — see src_vs_dist. The DEPENDENCY closure was built before any measurement (`pnpm --filter '@objectstack/service-messaging^...' build`, exit 0), because @objectstack/objectql and @objectstack/driver-sql DO resolve through dist." }, "src_vs_dist_proof": "Argued from the files, then confirmed empirically. (a) Every subject under test — SqlHttpOutbox, SqlNotificationOutbox, HttpDispatcher, NotificationDispatcher — is imported by a RELATIVE specifier ('./sql-http-outbox.js') from inside the same src/ directory; a relative import cannot leave src/, and only bare package specifiers reach a dist/ through `exports`. (b) `ls packages/services/service-messaging/dist` returned 'No such file or directory' AT THE TIME THE ABLATION RAN — there was no dist for the mutation to hide behind. (It exists now, created later by the plugin-webhooks closure build; stated so the claim is not read as still true.) (c) The package's vitest.config.ts declares exactly one alias, @objectstack/core to source, which touches none of these. (d) Empirically the src mutation flipped the verdict with no rebuild.", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs with NO path arguments, run on the final commit 1091f1e60 with a clean tree; 16 paths, exit 0. Re-derived after the last two commits — identical path set and identical gate set. Note the derived set includes packages/create-objectstack/bin/create-objectstack.js, which I did NOT modify (a pre-existing mode-only change, 0 insertions) and left alone per AGENTS.md.", "all_green_with_their_own_verdict_lines": [ "check:changeset-gate-self-tests — EXIT=0 — '✓ check-adr-0087-registration --self-test: 212 assertions over real temp git repos'", "check:objectui-changeset — EXIT=0 — '✓ objectui-changeset-digest --self-test: all checks passed'", "check:route-envelope — EXIT=0 — '✓ Plugin-mounted Hono routes — 12 module(s) audited … 8 conformant, 0 ratcheted, 3 exempt, 1 vendor-wire'", "check:slot-lookup — EXIT=0 — '✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new'", "check:test-source-alias — EXIT=0 — 'check-test-source-alias OK — 72 packages with tests scanned'", "check:type-source-resolution — EXIT=0 — 'check-type-source-resolution OK — 76 packages with a tsconfig.json scanned'", "check-adr-0087-registration — EXIT=0 — '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' (see repairs)", "check-changeset-no-major — EXIT=0 — '✓ This diff introduces no `major` bump.'", "check-ci-filter-parity — EXIT=0 — 'OK: all 82 declared cross-package glob(s) (71 unique) are covered'", "check-empty-changeset — EXIT=0 — '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).'", "check-plugin-teardown-shape — EXIT=0 — '✓ 61 Plugin implementation(s) across 4431 source(s) … baseline fully burned down'", "check-affected-docs — EXIT=0 — '✓ affected-docs self-test: 339 cases pass.'", "check:query-options-erasure — EXIT=0 — '✓ ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'", "check:type-check-coverage — EXIT=0 — 'OK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledger'", "check:type-check-debt — EXIT=0 — '--re-measure: OK — 33 ledger entr(ies) re-measured in 368.0s, 1908 raw tsc error(s) total, none above its recorded number' (workspace closure built first, as the gate demands)", "check:engine-double-contract — EXIT=0 — 'OK — 377 pinned, 133 in the DEBT ledger, 2 exempt'", "check:where-matcher — EXIT=0 — '✓ conformance holds: 276 matcher(s) discovered, 276 answer the combinator battery correctly or refuse it loudly'", "check:i18n — EXIT=0 — 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)' (see repairs)", "check:nul-bytes — EXIT=0 — 'OK (scanned 6352 text file(s) … no raw ASCII control bytes)' — NOT named by the derivation; run on judgment", "check:tenant-chokepoint — EXIT=0 — '✓ 20 getBuilder() binding(s) across 3 file(s); every read builder routes through applyTenantScope(), every unscoped one is an insert' — NOT named by the derivation; run on judgment because the card's subject is tenant scoping" ], "did_the_derivation_name_the_gates_i_judged_relevant": "NO for two of them — class #10309 confirmed live again. check:nul-bytes and check:tenant-chokepoint appear nowhere in the derived union despite this diff touching 15 source files and being entirely about tenant scoping. Both were run explicitly and both are green.", "repairs_before_measurement": [ "check-adr-0087-registration failed TWICE before it measured anything. (a) The marker spelled the symbol half as a member path (IHttpOutbox.redeliver); parseSymbolRef requires a bare exported identifier and refuses member paths by design. (b) After fixing it the gate STILL read the old text — it reads changesets from GIT, not the working tree (the named instrument trap; I walked into it and the fix was to commit). (c) It then refused `MessagingService` as UNRESOLVABLE, because packages/spec/src/api/protocol.zod.ts mentions the class name in a prose comment about MessagingService.listInbox while neither declaring nor importing it. Resolution: the marker names only the two symbols the gate can verify (IHttpOutbox, RedeliverOptions) and states the MessagingService.redeliverHttp claim in the marker's prose, where a reviewer reads it rather than where a checker would appear to have verified it. This is a judgment call and is flagged as such rather than buried.", "check:i18n first returned 'PREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked' — NOT MEASURED, not a pass, the same class as a gate that refuses. Rather than declare a narrowing I satisfied the prerequisite the gate itself prescribes (turbo run build --filter=@objectstack/cli) and re-ran it to a real verdict." ] }, "declared_narrowings": [ "17 of the 20 gates first ran at commit c3259cd48; the delta from there to the final head 1091f1e60 is a ONE-LINE prose change inside .changeset/*.md and nothing else. The six code ratchets (slot-lookup, query-options-erasure, engine-double-contract, where-matcher, plugin-teardown-shape, nul-bytes) were nonetheless RE-RUN on the final head and are green there. adr-0087, i18n and type-check-debt were themselves run after the final commit. So no gate's green is being reported from a tree that is no longer head.", "The full-farm gate set was not run locally — CI runs it exactly once regardless. The local set is the derived union plus the two judgment gates above." ], "tests": "All under the shared verify lock; every exit code captured BEFORE any pipe (`cmd > file 2>&1; EXIT=$?`), never after a tail. (1) Dependency closure: `pnpm --workspace-concurrency=2 --filter '@objectstack/service-messaging^...' build` EXIT=0. (2) `pnpm --filter @objectstack/service-messaging --filter @objectstack/plugin-webhooks typecheck` EXIT=0 — both 'Done'. The FIRST typecheck after the contract change was EXIT=2 with 10 errors across 4 files ('Expected 2 arguments, but got 1', 'Argument of type (row: any) => string is not assignable to parameter of type RedeliverOptions'), which is the required-property design working: omission could not compile and every call site had to state an answer. (3) New file `src/delivery-update-tenant-audit.integration.test.ts` — 'Test Files 1 passed (1) / Tests 6 passed (6)'. (4) New file `plugin-webhooks/src/webhook-redeliver-tenant-scope.test.ts` — 'Test Files 1 passed (1) / Tests 4 passed (4)'. (5) Full suites: service-messaging 'Test Files 25 passed (25) / Tests 254 passed (254)', plugin-webhooks 'Test Files 11 passed (11) / Tests 128 passed (128)', EXIT=0. (6) Ablation red/restore green with byte-identical restore hashes — see the ablation block; no subject rebuild was needed and the reason is proved from the files. Both new suites include a POSITIVE CONTROL on the same object through the same driver (an unscoped by-id update that MUST emit the audit line, run last because the gate throttles one warning per object:op) — and that control caught its own vacuity: pointed at a non-existent id it threw 'Record ... not found' from the engine BEFORE the driver, so it was re-pointed at a real row.", "open_questions": [], "out_of_scope_findings": [ "filed as #11009: a compare-and-set `where` on a by-id `update` is silently inert — SqlDriver.update builds `getBuilder(...).where('id', id)` and never applies options.where, so every predicate key besides `id` is discarded. MEASURED, not inferred: a `pending` row's attempts went 7 -> 0 under `{ where: { id, status: { $in: ['success','failed','dead'] } }, multi: false }`. SqlHttpOutbox.redeliver writes exactly that predicate as the atomic half of a check-then-act, so its guard does nothing and a row the dispatcher claims between the read and the write is reset anyway while the readback reports success. Pre-dates this card, different defect class, and its fix is a contract decision on ObjectQL.update (honour the keys / refuse the shape / fix the caller) — filed unassigned, untouched here. Duplicate search run before filing; nearest matches #4419, #5748, #6435 are all closed." ] }
Generated by Claude Code
huangyiirene commented
on Aug 22, 2026 CollaboratorMore actionsContract review — PASS (clause ②)
Reviewing seat: skills PM (
session_01ApyDuQY2fkunMCqXiqvBhR), machine-verified at tier this round (get_session→external_metadata.last_served_model = claude-fable-5=CONTRACT_REVIEW_TIERonorigin/main). Not the dispatching seat.Scope reviewed: the contract-face increment of draft PR #11010 at
1091f1e60, from the actual diff (15 files) — the interface, the service delegate, the SQL implementation, the route, the helper, and the changeset were each read; the rest sampled.Findings, checked against the diff:
- The signature shape is the right kind of forced.
RedeliverOptions.tenantIdis required and typedstring | undefined, so the dangerous case — a request path that simply forgot — cannot compile, while a genuinely tenant-less caller must writetenantId: undefinedand mean it. The report's typecheck evidence (10 errors across 4 files on first compile after the change) is this design working as the notification channel. - Cross-tenant behavior is invisible, not forbidden — deliberately. The scope is threaded to both reads and the write from one shared options bag, so the refusal surfaces as
RESOURCE_NOT_FOUNDand the endpoint is not an existence oracle for other tenants' delivery ids; the producer guard never sees a row the caller may not read. In-tenant redelivery unchanged (still-works pinned). - Zero
bypassTenantAuditon the redeliver path, verified in the diff and backed by the report's zero-hit counter-check with positive control. The scoped-vs-bypassed distinction is asserted at the driver (a spy on the one method that both audits and scopes), which is the only place the two are distinguishable — and the "tenant-less caller is NOT silenced" test makes a future bypass go red rather than green. - The two
acksites get their own helper, not a borrowed warrant.dispatcherAckOptions()ismulti: falseby type so the sweep helper cannot be confused for it, all four warrant limbs were re-derived on this tree, and the docblock names the tempting wrong answer (stamping the claimed row's ownorganization_id— a tautological predicate that silences the audit while adding no isolation) and why it is worse than the declared bypass. This is exactly the classification discipline the card demanded. - Route-side tenant resolution is single-sourced.
session.session.activeOrganizationId, read from the canonical spelling with no??chain over alternatives — so a missing organization stays visible (threadsundefined, audit fires) instead of being papered over. OnegetSessionlookup serves both the auth gate and the tenant, so they cannot disagree. - Changeset and ADR-0087 handling are honest. BREAKING signature change shipped
minorper the repo's launch-window convention; theadr-0087: not-required (runtime-interface-only …)marker names exactly the two symbols the gate can resolve (IHttpOutbox,RedeliverOptions) and states theMessagingService.redeliverHttpclaim in prose because the gate refuses that symbol as unresolvable — claiming it where a checker would appear to have verified it would be worse. Flagged as a judgment call by the seat; upheld here. MemoryHttpOutboxaccepting-and-ignoringtenantIdis documented in place with the reason (no tenant column, test/dev double) and the obligation on any future tenant-storing memory implementation. Acceptable.
Noted, not blocking: the seat's out-of-scope finding #11009 (a compare-and-set
whereon a by-idupdateis silently inert at the SQL driver) means redeliver'sstatus: $inguard predicate does not currently bind at the driver — pre-existing, correctly filed separately, and this PR's read-back check (after.status !== 'pending'→DELIVERY_NOT_ELIGIBLE) is what actually holds that line today.Verdict: PASS.
needs:contract-reviewcleared (four-step read-modify-write with read-back; label was issue-side only — PR #11010 carries none). Ready-flipping stays with the dispatching seat.
Generated by Claude Code
- The signature shape is the right kind of forced.
- added a commit that references this issue
on Aug 31, 2026 - added a commit that references this issue
on Sep 1, 2026
Found while implementing #10673 (PR #10725), which classified the
updateManyhalf of the tenant-audit surface onsys_http_delivery/sys_notification_delivery. Filed separately rather than widened into that PR: one of the two sites here needs a contract decision, which fails the bounded-inline-fix test.⛔ Read the two classifications before touching either. They are opposite, and the dangerous mistake is to treat this as one sweep.
The surface
The same two objects carry three single-record (
multi: false) writes that the SQL driver audits under theupdateop — a different op from theupdateManyone #10673 covered:SqlNotificationOutbox.ackSqlHttpOutbox.ackSqlHttpOutbox.redeliverPOST /api/v1/webhooks/redeliverplugin-webhooks' own comment on that route says any authenticated user can reach it. So on a deployment withOS_TENANCY_POSTURE=isolated,redeliveris an unscoped write reachable by an authenticated user — exactly the case the tenant audit exists to catch, and the one case where silencing the audit line would convert a detectable hole into an undetectable one.Why this is not simply "finish the job from #10673"
#10673's six sites earned
bypassTenantAuditon a structural warrant, not a judgement call: their only callers are the dispatchers'setIntervaltick under a cluster lock (no request context exists on that path);ClaimOptions/HttpClaimOptionsare{nodeId, limit, partition, claimTtlMs, now}and carry no tenant even in principle; partitioning ishash(refId|notificationId|digestKey) mod N— a load-spreading key, not an org key, so a partition holds every org's rows by construction; and one outbox per environment drains the whole queue, so a per-org predicate would strand every other org's deliveries forever and leave a crashed node's rows permanentlyin_flight.redeliversatisfies none of that. It has a request context, it is reachable by a user, and the remedy is to thread a tenant, not to declare a sweep — which changes theIHttpOutbox.redelivercontract. That is the decision this card exists to get made.#10673's card quotes a composed-boot startup log showing exactly two
[tenant-audit]lines. That is not evidence that theupdateop is clean:Anyone re-measuring this should drive an actual delivery through, not read a boot log.
What a fix must decide
ack× 2 — carry the same documented warrant (the helperpackages/services/service-messaging/src/outbox-dispatcher-scope.ts, added by PR fix(messaging): classify the delivery dispatchers' updateMany sweeps as global environment sweeps #10725, already holds the reasoning; note its return type is& { multi: true }, so it cannot be reused here as-is and that is deliberate).redeliver— thread the request's tenant. This changesIHttpOutbox.redeliver's signature, so it is a contract change and wants the contract-review tier. ⛔ It must not be givenbypassTenantAuditto make the line go away — the audit's whole purpose is to force this classification, and this is the site it was built for.Refs
updateManyon tenant-scopedsys_http_delivery/sys_notification_deliveryruns withoutoptions.tenantId— un-isolated writes on multi-org shapes #10673 / PR fix(messaging): classify the delivery dispatchers' updateMany sweeps as global environment sweeps #10725 — theupdateManyhalf, and the structural warrant the twoacksites inheritpackages/services/service-messaging/src/sql-http-outbox.ts,sql-outbox.ts— the three sitespackages/drivers/driver-sql/src/sql-driver.ts—auditMissingTenant, the instrumentcrm_contracthalf is not this and is not reached for hereFiled unassigned for triage. Duplicate search run before filing (nothing open beyond #10673 itself).