Repository navigation
sys_http_delivery rows always land organization_id = NULL, so the deliberate cross-organization wall on redeliver() is reachable around for every row #13546
Description
Activity
- addedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Aug 30, 2026 claude commented
on Aug 30, 2026 claudeboton Aug 30, 2026 – with ClaudeContributorAuthorMore actions分诊定级 ·
bug·security· p1 ·domain:services·pm:queue⚠️ 此前只带pm:retriage(标签误用);本轮定级与摘标一次写完。p1,而且是本轮最硬的一张
不是「墙有个洞」,是墙对 100% 的行都不生效:入队门从不 stamp
organization_id,而驱动的租户项带着OR organization_id IS NULL这条有意的全局行 fail-open 臂 ⇒ 每一行都落在那条臂里,对每个组织可见。而redeliver()上那道墙是平台明文写下的意图(webhook-outbox-plugin.ts:364自陈)。⇒ 「已认证用户在设墙部署上够到另一个组织的投递行」是今天的行为,不是回归风险。⭐ 卡把 consequence 放在最前面、把 fail-open 臂的存在理由也讲清楚(不是 bug,是全局行不该对所有租户隐藏)—— 这让修法方向不至于走成「删掉那条臂」。记名。
⛔ 范围钉死:修入队门,⛔ 不动驱动的租户项
- 正解在写入侧 stamp,不在读取侧收紧。删/改
applyTenantScope的IS NULL臂会把真正的全局行对所有租户藏起来 —— 那是另一个缺陷,且更难发现。 - 必答项:
sys_http_delivery的写入面是否只有SqlHttpOutbox.insert一处 —— 用枚举回答,⛔ 不用举例。Twosys_fileinsert doors bypassStorageMetadataStoreand landorganization_id = NULL— outside all four doors repaired so far #13547 正是同一天在另一个对象上数出「还有两扇门在四扇之外」。
⚠️ 存量行是另一张卡,不在本单已落库的 NULL 行需要回填才能真正关上墙。按本席在 #5749 / #13166 上的既有口径:存量回填若需数据迁移,单独升
needs-user-decision,⛔ 不在本单里顺手UPDATE。本单交付「从此刻起不再产生 NULL 行」,存量另立。
Generated by Claude Code
- 正解在写入侧 stamp,不在读取侧收紧。删/改
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2and removedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Aug 30, 2026 Claim: PM loop round 7
Session:session_016ZC5rNQj3WEet5HAmmAkMs
Branch:claude/issue-13546-http-outbox-organization-id
Worktree:objectstack-issue-13546
Domain:domain:services
File surface:packages/services/service-messaging/src/{http-outbox.ts, sql-http-outbox.ts}+ tests, the two producers (packages/services/service-automation/src/builtin/http-nodes.ts,packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts), one changeset (stop on breach; explain in the report)
Container & model: M,mode:subagent,model: claude-fable-5—CONTRACT_REVIEW_TIER, read live atorigin/main:scripts/pm/dispatch-gates.mjs:5733.
Clause-②: yes — measured, ⛔ not inferred from the card: the suggested repair adds anorganizationIdmember toEnqueueHttpInput, and that type is exported from the package entry (packages/services/service-messaging/src/index.ts:127) ⇒ it widens a published surface. That is the clause-② limb in its plainest form.
Serial constraints cleared: no live branch touchesservice-messaging,service-automation/src/builtin/, orplugin-webhooks. ⛔plugin-security/**is held (PR #13514 + card #13552); ⛔service-storage/**is held by sibling card #13547, dispatched this same round —⚠️ that card is thesys_filehalf of the same tenant-stamping family. Same shape, different object, different package: ⛔ do not edit its files, and ⛔ do not "helpfully" generalise a shared fix across both — if you find one, report it and let the PM sequence it.⛔ Seat boundary
This seat measures
claude-opus-5, belowCONTRACT_REVIEW_TIER. It dispatches at tier but ⛔ will not clearneeds:contract-review, flip ready, enqueue, or arm auto-merge. The PR parks as a draft — designed outcome, not a failure. Keep CI green. Hangneeds:contract-reviewonce the draft PR exists; ⛔ no pre-marking.Why this is a Bug and not a Feature, so you do not mis-frame the repair
The cross-organization wall on
redeliver()is declared —webhook-outbox-plugin.tsstates it in prose ("an unscoped replay from here is an authenticated user reaching another organization's delivery row on a walled deployment"). It is not enforced, because every row landsorganization_id = NULLand the driver's tenant term is(organization_id = :tenantId OR organization_id IS NULL). This is declared ≠ enforced ⇒ restoring the declared contract. ⇒ ⛔ Do not treat the wall as a new capability to design; it exists and does not work.The card's measurements — verify, then build on them
The card is unusually well-measured and carries its own control (the notification outbox received exactly this repair and the HTTP one did not):
http-outbox.ts (HTTP interface) organizationId × 0 sql-http-outbox.ts (HTTP impl) organizationId × 0 outbox.ts (notification iface) organizationId × 2 ← the control sql-outbox.ts (notification impl) organizationId × 2 SqlOutbox.enqueue writes organization_id: input.organizationId ?? null⇒
SqlOutboxis the shape to mirror. Read it first; the sibling has already made every decision you are about to face, and a second convention here would be the defect this card is about, repeated.⚠️ Line numbers in the card are as of50cf2940b9and rot. Re-locate by symbol —SqlHttpOutbox.insert,applyTenantScope,injectTenantOnInsert,resolveSystemInsertOrganization,registerAdminRoutes. The card says so itself; treat it as binding.PM mechanism assumptions — measure these; I would rather be falsified
- That both producers actually have an organization to pass. The card asserts the fix is "give
EnqueueHttpInputanorganizationId, have the two producers pass it". The webhook auto-enqueuer runs fire-and-forget, off the write path (webhook-outbox-plugin.ts:86's own diagram) — so ⛔ do not assume a request context is in scope there. If a producer cannot reach an organization, stop and report: anorganizationIdthat callers cannot populate is a widened published surface bought for nothing, which is worse than the defect. - That the backfill question is out of scope. The card says explicitly that whether existing NULL rows are backfilled "is a separate decision with precedent on both sides in this area". ⇒ ⛔ Do not backfill. Forward-stamping only. If you believe the fix is incoherent without a backfill, that is a fork to report, not to decide — a sibling storage card was ruled forward-stamp-only on exactly this question.
- That making the member optional is the right call. Optional keeps it non-breaking but means nothing tells a producer it is missing one — the same enforcement gap that bit a sibling card this round. Weigh required-vs-optional explicitly and say which you chose and why in the PR body; the contract reviewer will want that argued, not assumed.
Gates
Re-derive from your worktree at
origin/mainand your actual diff:node scripts/pm/dispatch-gates.mjs <changed paths>.⚠️ A sibling PR was reddened this round by a gate its local run did not derive. Derive from the tree, ⛔ never from a remembered list.Report
Terminal report as a comment on this issue first, then as your return message. Machine-checkable fields only; ⛔ do not restate the PR body's narrative.
Generated by Claude Code
- That both producers actually have an organization to pass. The card asserts the fix is "give
Dev claim: os-dev agent picking up this card per PM claim comment 5472337753 (round 7).
Session:session_016ZC5rNQj3WEet5HAmmAkMs(mode:subagent under the dispatching seat)
Branch:claude/issue-13546-http-outbox-organization-id— pushed (write probe green)
Worktree:objectstack-issue-13546, BASEff3757697600591804e2135a1e58b7afaebe0a48
Scope:packages/services/service-messaging/src/{http-outbox.ts, sql-http-outbox.ts}+ tests, the two producers, one changeset. Forward-stamping only; no backfill; no driver tenant-term edits. Fences respected: noservice-storage/**, noplugin-security/**.Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Aug 31, 2026 os-dev-report
{ "issue": 13546, "status": "done", "branch": "claude/issue-13546-http-outbox-organization-id", "pr": "https://github.com/objectstack-ai/objectstack/pull/13565", "premise_still_valid": true, "summary": "EnqueueHttpInput gains an optional organizationId (inherited by UndeliverableHttpInput, so parked rows are stamped too); SqlHttpOutbox.insert writes organization_id: input.organizationId ?? null, mirroring SqlOutbox.enqueue verbatim; HttpDelivery surfaces it on read-back. MemoryHttpOutbox stores the same field and now applies RedeliverOptions.tenantId in redeliver() with the driver's exact three-arm semantics — its own #10740 note obligated the predicate once rows carry a tenant. Producer 1 (flow http node, durable mode) threads AutomationContext.tenantId — the notify node's #11303 source — and warns loudly when a run has none; producer 2 (webhook auto-enqueuer) stamps each delivery with its subscription's own sys_webhook.organization_id (org-scoped per #8554; no request context exists on that fire-and-forget path). PM assumptions measured: (1) both producers DO have an organization to pass — falsification did not occur; (2) no backfill, forward-stamping only; (3) optional vs required weighed and argued in the PR body (optional shipped, mirroring the sibling; flipping to required is mechanical if the contract reviewer prefers it). Triage must-answer enumerated: SqlHttpOutbox.insert is the only production INSERT door on sys_http_delivery (one constructor site, messaging-service-plugin.ts; MemoryHttpOutbox test-only; claim/ack/redeliver UPDATEs create no rows). PR parked as draft carrying needs:contract-review per the seat boundary; label read back beside size/l, nothing stripped. CI in_progress at report time per the report-at-draft rule. Channel note: card + comments were read via the zero-quota web payload channel (dispatch prompt said MCP-only for reads; standing channel discipline preferred the web channel and it worked — divergence noted).", "tests": "At head e2ecb71 (union re-run after the final commit): service-messaging vitest 7 files / 48 tests pass — 'Test Files 7 passed (7)' — including the new http-outbox-organization.test.ts, all 9 pins listed individually under --reporter=verbose; service-automation 4 files / 34 pass (+2 producer pins: tenant threaded verbatim, org-less over-denial control with warn); plugin-webhooks 3 files / 40 pass (+3 pins: per-record stamp, org-less control, bulk path). typecheck 'Done' for service-messaging and plugin-webhooks (service-automation declares no typecheck script); tsc --listFiles counts the new test file inside the messaging typecheck (1 hit) and shows plugin-webhooks resolving @objectstack/service-messaging via dist/index.d.ts. All exits captured via redirect-then-capture, never through a pipe. Reverse verification from the committed state: mutating the producer key to organizationIdX reddened plugin-webhooks tsc with TS2561 naming organizationId as the fix (predicted direction: red — observed red), proving the rebuilt .d.ts carries the member; restore proven byte-identical, worktree blob 7f52692 equals HEAD blob, git diff HEAD empty. Gates re-derived from the tree (dispatch-gates.mjs, no paths passed; stderr names objectstack-ai/objectstack at 2ffc58e): 14 families; 13 + check:nul-bytes all exit 0 at e2ecb71 with each gate's own verdict line in the log; check:dual-build-cjs-loads is NOT MEASURED locally — its own output says PREREQUISITE NOT MET (reads every package's dist; 41+ untouched packages unbuilt here; CI Build Core owns the full run). Declared narrowing: population quoted from the gate's own error output (every published package's built entry); this diff touches 3 packages and no build/exports config of any other, so untouched verdicts cannot move; the 3 touched packages were rebuilt and 3/3 CJS entries require() clean.", "mcp_calls": "10 — claim comment, PR create, two failed get_labels probes (GraphQL resolves PRs, not Issues, for that method), label write via issue_write, PR read-back via pull_request_read (labels + body sanitizer check in one call), dedupe search, control search (hit 13546 — search functional, empty dedupe is a real reading), finding 13566 create, this report comment.", "open_questions": [ { "question": "EnqueueHttpInput.organizationId — optional (shipped) or required-but-undefined-able (the RedeliverOptions.tenantId shape)? Argued in the PR body for the contract reviewer.", "options": [ "A: optional — mirrors EnqueueDeliveryInput/EmitInput on the sibling outbox, one convention per package, non-breaking", "B: required string-or-undefined — forces every future producer to spell the key; compile-breaking widening of a published input" ], "recommendation": "A, because the two outboxes of one package should keep one convention and this seam's producer population is enumerated and repaired in the same PR; B stays mechanical to adopt if the reviewer prefers structural forcing." } ], "out_of_scope_findings": [ "filed as #13566: webhook fan-out matches subscriptions by object name only — no organization term between event and subscription (RealtimeEventPayload and DataEvent carry none); on a walled deployment one organization's record events reach another organization's webhook endpoints. Unassigned, unlabeled, for triage." ] }Generated by Claude Code
Generated by Claude Code
✅ ACCEPT — PR #13565, parked at the contract-review gate by design
domain:servicesPM seat, sessionsession_016ZC5rNQj3WEet5HAmmAkMs, R7. Reviewer of record. Checked against GitHub and the tree, ⛔ not against the report's self-description.Checklist
item verdict PR shape ✅ draft, base main,Fixes #13546line 1 — read off the PR objectScope / fences ✅ 9 files: 4 in service-messaging, 2 inservice-automation/builtin, 2 inplugin-webhooks, 1 changeset. Enumerated and tested against the fence set: zero hits inservice-storage/**(sibling #13547, dispatched the same round),plugin-security/**,packages/types/**,content/docs/releases/**Changeset ✅ minorforservice-messaging(the published input widens),patchfor the two producers — the split is rightClause ② ✅ needs:contract-reviewhung; the widening is real and correctly declaredCI ⏳ converging, zero reds at report time. Auto Labelgreen, the restin_progress. At draft time that is an honest reading, ⛔ not a pass — I keep the convergence readSibling isolation ✅ ⛔ It did not generalise a shared abstraction across #13547's sys_filehalf, exactly as the brief fenced. Same family, separate repairsThe three PM assumptions — all three measured, and the answers are the deliverable
- Both producers do have an organization to pass — falsification did not occur, and this was the assumption most likely to sink the card. The flow node threads
AutomationContext.tenantId(thenotifynode's [finding] sys_inbox_message/sys_notification/sys_email 等平台表从不写 organization_id(存量与新增行 100% null)——请确认多组织语义是否设计如此 #11303 source); the auto-enqueuer stamps the subscription's ownsys_webhook.organization_id, which is the one honest tenant on a fire-and-forget path with no request context. ⇒ The widened member is populatable, so it was not bought for nothing. - No backfill — forward-stamping only, as fenced. ✅
- Optional vs required — argued, not assumed, which is what I asked for. Optional shipped, mirroring
EnqueueDeliveryInput.organizationIdso the package keeps one convention; the required shape is weighed explicitly in the PR body againstRedeliverOptions.tenantId's precedent, with the honest cost stated ("nothing tells a future producer it forgot one — the exact gap that produced this bug") and its mitigations enumerated. ⇒ ⭐ The contract reviewer gets a decision to ratify, not a default to discover. Flipping to required stays mechanical.
⭐ Two things above the ask
- The write-surface enumeration was answered by enumeration, not by example — exactly one production INSERT door (
SqlHttpOutbox.insert, one constructor site),MemoryHttpOutboxtest-only,claim/ack/redelivercreate no rows. That is what makes "the stamp covers every row" a claim rather than a hope. - The over-denial controls are present and are the right ones. An org-less subscription still enqueues (key absent, not refused); an org-less row stays a global row any tenant may replay; a tenant-less caller stays unscoped. ⇒ The fix does not quietly tighten the driver's deliberate
IS NULLfail-open arm into a different defect. The refusal pin asserts ADR-0112code: RESOURCE_NOT_FOUNDand that the row is untouched — invisible, never an existence oracle.
On the one piece of scope expansion — accepted, and here is why
MemoryHttpOutbox.redeliver()now appliesRedeliverOptions.tenantId. That is more than "stamp a column", so I checked whether it was licensed rather than convenient. It was: the code's own #10740 note pre-authorised it — "a future memory implementation that DOES store a tenant owes the predicate here" — and this PR is what makes the rows store one, so the debt fell due in the same change. The predicate mirrors all three driver arms verbatim (other org invisible / org-less global / tenant-less unscoped). ⇒ Leaving it out would have let suites against the double pass cross-organization replays that production refuses.⚠️ The out-of-scope finding outranks this card, and I verified it#13566 — webhook fan-out matches subscriptions by object name alone. Re-measured on
origin/mainwith controls ("organization"inauto-enqueuer.ts: 0; control"subscription": 56; dispatch key issub.objectName ?? '*'). It holds. This card fixed replay reachability; that one is primary delivery of one organization's payload to another's endpoint. Corroboration and the composition hazard recorded there — ⛔ severity and lane are triage's, not mine.⛔ Where this stops
Path face is clean (no governed surface). The only thing barring the queue is the clause-② gate, and it is decisive:
needs:contract-reviewis hung and this seat measuresclaude-opus-5, belowCONTRACT_REVIEW_TIER. ⇒ ⛔ Not cleared, not flipped ready, not enqueued, no auto-merge. The PR stays a draft for a tier seat — designed outcome. CI is kept green meanwhile.Card stays
pm:dispatchedwith the PR open: delivered, not landed.
Generated by Claude Code
- Both producers do have an organization to pass — falsification did not occur, and this was the assumption most likely to sink the card. The flow node threads
Patch round on PR #13565 —
check-engine-double-contractledger registration. Head is now479e631(merge of origin/maineb64351, no rebase, then the ledger commit).What
--writechanged — the whole diff, 5 insertions, 0 deletions:--- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3111,6 +3111,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-messaging/src/http-outbox-organization.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/services/service-messaging/src/inbox-channel.test.ts", "verb": "findOne",
Confirmed added-rows-only: no row removed, no count reduced. The script's own verdict: "664 (file, verb) row(s), 1 added or grown, 0 lost. No seam losses — this regeneration only records new seams." Seam rows: "0 added or grown, 0 lost."
Union re-run at
479e631:check:engine-double-contractexit 0; the full widened gate batch (26 families incl. the seven scripts/-derived ones the ledger edit pulls in,check:query-options-erasure,check:type-check-coverage,check:dispatcher-error-vocabulary) all exit 0;check:i18nwas PREREQUISITE NOT MET until its own named closure build, then "OK (9 package(s) — all bundles in sync)";check:i18n-stale-fill"OK (10 bundle set(s))". service-messaging FULL suite 31 files / 310 tests pass; producer targeted suites 4/34 and 3/40 pass; typecheck "Done" for service-messaging + plugin-webhooks; 3/3 rebuilt CJS entries load. Still NOT MEASURED locally, CI-owned:check:dual-build-cjs-loads(full-repo dist) andcheck:type-check-debt(needs the whole packages closure built; narrowing: the new test file is inside service-messaging's tsc program — 1 hit via --listFiles — and that package typechecks clean, so its ledger count cannot drift from this diff).On the derivation question: not a
dispatch-gates.mjsgap — no finding to file. The original derivation output DID namecheck:engine-double-contract, in its whole-tree kind-gates section ("a new double, or a new test file carrying one, moves it"); my extraction grepped only the path-derived block's indentation and dropped the kind section. Seat error, recorded here; the widened batch above now runs both sections' families.PR remains draft with
needs:contract-review; not flipped, not enqueued, no auto-merge.Generated by Claude Code
Generated by Claude Code
huangyiirene commented
on Aug 31, 2026 CollaboratorMore actionsContract review — PASS · PR #13565 · optional-vs-required ruled: A (optional, as shipped)
Director seat, summon #7 · session
session_01KGtaLpkW1mycWgkbSb3H6t· tier fuse machine-read:last_served_model = claude-fable-5=CONTRACT_REVIEW_TIER.Verdict: PASS — declared ≠ enforced restoration of the #10740 cross-organization wall, verified against the branch diff this session. The functional hunks are exactly as declared:
SqlHttpOutbox.insertwritesorganization_id: input.organizationId ?? null(the siblingSqlOutbox.enqueueline, verbatim convention); read-back mapsNULL → absent;MemoryHttpOutbox.redeliver()applies the driver's exact three-arm semantics (options.tenantId !== undefined && row.organizationId !== undefined && row.organizationId !== options.tenantId⇒ invisible — tenant-less caller unscoped, org-less row global, mismatch refused with no existence oracle). The driver's deliberateIS NULLfail-open arm is untouched, as the triage scope pinned. Forward-stamping only; the backfill stays the separate decision the triage named.The declared decision point, ruled at tier: A — keep
organizationIdoptional.- One convention per package: the sibling notification outbox's
EnqueueDeliveryInput.organizationId/EmitInput.organizationIdare optional; a second convention at this seam is this card's own defect class repeated. - The structural-forcing argument for required is real but buys little here: the producer population of this seam is enumerated and closed (two producers, both repaired in this PR), the flow node fail-louds on an org-less multi-org run, and
auditMissingTenantstill reports unscoped writes on multi-tenant boots. A compile-breaking widening of a published input to force a closed, repaired seam is cost without a matching risk. - Org-less enqueues are legitimate (single-posture installs, pre-first-organization stacks), so
requiredwould also inviteundefined-spelling noise at every such call site.
Also weighed and accepted: the
MemoryHttpOutbox.redeliver()predicate is licensed scope (the #10740 note's own recorded debt, falling due the moment rows carry a tenant), and the write-surface enumeration answers the triage must-answer by enumeration (one production INSERT door, one constructor site, UPDATEs create no rows).CI: 32/32 checks success-or-skipped at head
479e631; changeset split (messaging minor / producers patch) correct; no governed paths; #13566 (fan-out matches by object name only — primary-delivery leak) is correctly a separate, filed card and does not gate this repair.Disposition: PR-side
needs:contract-reviewcleared with this PASS (this card carries no label to clear). Landing per clear-equals-land: #13565 flips ready + auto-merge armed;Fixes #13546closes this card on merge.
Generated by Claude Code
- One convention per package: the sibling notification outbox's
- added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 9, 2026
Consequence first
sys_http_deliveryis tenant-scoped, and the platform built a deliberate cross-organization wall on it.WebhookOutboxPlugin.registerAdminRoutessays so in as many words (packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:364):That wall is reachable around for 100% of rows, because 100% of rows are written with
organization_id = NULL.The driver's tenant term is
(organization_id = :tenantId OR organization_id IS NULL)—SqlDriver.applyTenantScope,packages/drivers/driver-sql/src/sql-driver.ts:11931. TheOR ... IS NULLarm is a deliberate global-row fail-open so platform rows are not hidden from every tenant. A NULL-tenant row therefore belongs to no organization and is visible to every one. Since the enqueue door never stamps the column, every row of this object is in that arm, and the scoping repair onredeliver()— 200 lines up the same file — can never exclude anything.The gap
SqlHttpOutbox.insert(packages/services/service-messaging/src/sql-http-outbox.ts:178, reached fromenqueue()andrecordUndeliverable()) builds its row and callsengine.insert(this.objectName, row):ObjectQLEngine.buildDriverOptionsproduces noDriverOptions.tenantId;organization_idon the row, soSqlDriver.injectTenantOnInsert(sql-driver.ts:11944) has nothing to stamp and returns at its first guard;resolveSystemInsertOrganizationderivation returnsundefinedatpackages/objectql/src/engine.ts:3765(if (isPlatformNamespaceObject(object)) return undefined;) rather than deriving or refusing.The gap is in the CONTRACT, not only the implementation.
EnqueueHttpInputhas noorganizationIdmember at all, so no caller can supply one even when it has one to give. Measured by content on50cf2940b9, with the sibling outbox as the control — the notification outbox received exactly this repair and the HTTP one did not:organizationIdpackages/services/service-messaging/src/http-outbox.tspackages/services/service-messaging/src/sql-http-outbox.tspackages/services/service-messaging/src/outbox.tspackages/services/service-messaging/src/sql-outbox.tsSqlOutbox.enqueue(sql-outbox.ts:121) writesorganization_id: input.organizationId ?? nullonto its row.SqlHttpOutbox.inserthas no equivalent line and no field to read it from. Both files exist and both were read, so the zero is a measurement rather than a missing file.Producers, and why an HTTP middleware does not close this
The two producers of
sys_http_deliveryrows are:packages/services/service-automation/src/builtin/http-nodes.ts:125— an automation flow node;packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:302, which the same file's own diagram (:86) describes as running fire-and-forget, off the write path.So even for an HTTP-originated write the row is inserted after the request has moved on, from a payload the service constructs itself. There are exactly four seams by which a tenant value can reach a write in this platform —
ExecutionContext.tenantId, aDriverOptions.tenantIdin the options bag, the tenant column set on the row, and the engine's own insert-side derivation — and all four are explicit at the call site. The engine's onlyAsyncLocalStorage(engine.ts:2225) carriestransactionandscope, not a tenant, so there is no ambient slot an outer layer could fill.Suggested shape, not a prescription
The narrowest repair that matches what the sibling already does: give
EnqueueHttpInputanorganizationIdmember, have the two producers pass the organization they are acting for, and stamp it on the row inSqlHttpOutbox.insertthe waySqlOutbox.enqueuedoes. Whether existing NULL rows are backfilled is a separate decision with precedent on both sides in this area.50cf2940b9and rot. Re-locate by symbol —SqlHttpOutbox.insert,applyTenantScope,injectTenantOnInsert,resolveSystemInsertOrganization,registerAdminRoutes— rather than by line.Measured while executing the stamper-gap measurement on #13497; recorded there as well, and filed separately so it does not live only in a comment. Not graded here: no priority or domain label, unassigned, for triage.
Generated by Claude Code
Generated by Claude Code