Skip to content

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

@os-warren

Found while implementing #10673 (PR #10725), which classified the updateMany half of the tenant-audit surface on sys_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 the update op — a different op from the updateMany one #10673 covered:

site reachable from classification
SqlNotificationOutbox.ack dispatcher tick only global sweep — same warrant as #10673's six
SqlHttpOutbox.ack dispatcher tick only global sweep — same warrant
SqlHttpOutbox.redeliver POST /api/v1/webhooks/redeliver ⛔ request-contextual — a bypass must NEVER be put here

plugin-webhooks' own comment on that route says any authenticated user can reach it. So on a deployment with OS_TENANCY_POSTURE=isolated, redeliver is 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 bypassTenantAudit on a structural warrant, not a judgement call: their only callers are the dispatchers' setInterval tick under a cluster lock (no request context exists on that path); ClaimOptions / HttpClaimOptions are {nodeId, limit, partition, claimTtlMs, now} and carry no tenant even in principle; partitioning is hash(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 permanently in_flight.

redeliver satisfies 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 the IHttpOutbox.redeliver contract. That is the decision this card exists to get made.

⚠️ Why the startup log did not show it — the evidence surface is narrower than it looks

#10673's card quotes a composed-boot startup log showing exactly two [tenant-audit] lines. That is not evidence that the update op is clean:

The reap UPDATE runs unconditionally on every dispatcher tick, while ack / redeliver only run once a delivery is actually processed. A startup log taken on an empty queue therefore shows exactly the two updateMany lines and says nothing at all about the update-op surface.

Anyone re-measuring this should drive an actual delivery through, not read a boot log.

What a fix must decide

  1. ack × 2 — carry the same documented warrant (the helper packages/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).
  2. redeliver — thread the request's tenant. This changes IHttpOutbox.redeliver's signature, so it is a contract change and wants the contract-review tier. ⛔ It must not be given bypassTenantAudit to 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

Filed unassigned for triage. Duplicate search run before filing (nothing open beyond #10673 itself).

Activity

  1. added theissue type on Aug 21, 2026
  2. huangyiirene commented on Aug 21, 2026

    @huangyiirene
    Collaborator

    Triage (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; redeliver threads the request's tenant and must NEVER get bypassTenantAudit — 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 changes IHttpOutbox.redeliver's signature: claim with Clause-②: yes and 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

  3. self-assigned this
    on Aug 22, 2026
  4. os-warren commented on Aug 22, 2026

    @os-warren
    CollaboratorAuthor

    Claim: dispatched.

    • session: user-29 [974948]
    • branch: claude/issue-10740-tenant-audit-update-half
    • worktree: ../objectstack-10740
    • Clause-②: YES — redeliver's remedy changes IHttpOutbox.redeliver's signature. Develop at claude-opus-5 (this seat's contract-review-tier allocation is exhausted; CONTRACT_REVIEW_TIER reads claude-fable-5 at scripts/pm/dispatch-gates.mjs:1932). PR stays draft; needs:contract-review hung as the compensating control. ⛔ This seat does not clear it.
    • serial constraint: packages/services/service-messaging (sql-http-outbox.ts, sql-outbox.ts) and outbox-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 bypassTenantAudit to 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_decision and 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.ack dispatcher tick only global sweep — inherits #10673's structural warrant
    SqlHttpOutbox.ack dispatcher tick only global sweep — same warrant
    SqlHttpOutbox.redeliver POST /api/v1/webhooks/redeliver ⛔ request-contextual

    plugin-webhooks' own comment on that route says any authenticated user can reach it. Under OS_TENANCY_POSTURE=isolated that 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 ack sites earn their warrant structurally, not by judgement: their only callers are the dispatchers' setInterval tick under a cluster lock; ClaimOptions / HttpClaimOptions carry no tenant even in principle; partitioning is hash(...) 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 permanently in_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 are multi: false sites under the update op, a different op from the updateMany one #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 the update op is clean, and the card says why:

    The reap UPDATE runs unconditionally on every dispatcher tick, while ack / redeliver only run once a delivery is actually processed. A startup log taken on an empty queue therefore shows exactly the two updateMany lines and says nothing at all about the update-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

    • redeliver carries 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 ack sites still audit-clean under their warrant, and a delivery actually flows.
    • a cross-tenant redeliver attempt is refused, with ADR-0112 code AND status.
    • 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. Prove src/ vs dist/ 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.mjs with 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. Never git stash (shared stack). Never report a grep hit count as a fact count.

    ⛔ Do not touch content/docs/releases/**. ⛔ Zero packages/spec ownership 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's crm_contract half. Do not reach for it.

    Refs

    #10673 / PR #10725 (the updateMany half and the structural warrant) · sql-http-outbox.ts, sql-outbox.ts · sql-driver.ts auditMissingTenant (the instrument) · cloud#1512 (upstream)


    Generated by Claude Code

  5. os-warren commented on Aug 22, 2026

    @os-warren
    CollaboratorAuthor

    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

  6. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Contract 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_TIER on origin/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:

    1. The signature shape is the right kind of forced. RedeliverOptions.tenantId is required and typed string | undefined, so the dangerous case — a request path that simply forgot — cannot compile, while a genuinely tenant-less caller must write tenantId: undefined and 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.
    2. 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_FOUND and 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).
    3. Zero bypassTenantAudit on 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.
    4. The two ack sites get their own helper, not a borrowed warrant. dispatcherAckOptions() is multi: false by 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 own organization_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.
    5. 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 (threads undefined, audit fires) instead of being papered over. One getSession lookup serves both the auth gate and the tenant, so they cannot disagree.
    6. Changeset and ADR-0087 handling are honest. BREAKING signature change shipped minor per the repo's launch-window convention; the adr-0087: not-required (runtime-interface-only …) marker names exactly the two symbols the gate can resolve (IHttpOutbox, RedeliverOptions) and states the MessagingService.redeliverHttp claim 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.
    7. MemoryHttpOutbox accepting-and-ignoring tenantId is 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 where on a by-id update is silently inert at the SQL driver) means redeliver's status: $in guard 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-review cleared (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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions