Uh oh!
There was an error while loading. Please reload this page.
fix(service-messaging,plugin-webhooks): classify the delivery outboxes' update-op tenant-audit surface — ack is a dispatcher sweep, redeliver threads the caller's tenant - #11010
Conversation
…-audit surface — ack is a dispatcher sweep, redeliver threads the caller's tenant Three single-record (multi:false) writes on sys_http_delivery / sys_notification_delivery are audited under the `update` op, and their classifications are opposite: - SqlNotificationOutbox.ack / SqlHttpOutbox.ack are reachable only from the dispatchers' setInterval tick under a cluster lock. Declared global sweeps via the new dispatcherAckOptions() helper, whose warrant is re-derived from this tree rather than inherited from the updateMany half. - SqlHttpOutbox.redeliver is served to any authenticated user through POST /api/v1/webhooks/redeliver. It now carries the caller's tenant, to the rows it reads as well as the row it writes, and never bypassTenantAudit. IHttpOutbox.redeliver(id, options) takes a required-but-nullable tenantId so a caller cannot omit the decision, and the webhook route threads the session's active organization into it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…d the ADR-0112 code+status on a cross-tenant refusal Adds webhook-redeliver-tenant-scope.test.ts, the half the service-level test cannot reach: that the tenant comes FROM THE REQUEST (the session's activeOrganizationId) and that the cross-tenant refusal surfaces as RESOURCE_NOT_FOUND with HTTP 404 rather than a 500. Also carries the changeset and the last redeliverHttp call site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…path>#<Symbol> The marker named `IHttpOutbox.redeliver` / `MessagingService.redeliverHttp`, member paths the gate's parseSymbolRef refuses by design: the symbol half must be a bare identifier it can find as an exported type declaration. Names the three declarations instead and says which members moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
check-adr-0087-registration refuses `MessagingService` as unresolvable — a prose comment in packages/spec/src/api/protocol.zod.ts mentions the class name without declaring or importing it, so the gate cannot rule it unrelated. The claim moves into the marker's prose, where a reviewer reads it rather than a checker appearing to have verified it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0dc14bb02e38e65741a6a7a1c03b3dd3009d9109 && git checkout 0dc14bb02e38e65741a6a7a1c03b3dd3009d9109
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 490879ad0fe22c57d74799828a49feac6860757a 1091f1e60a5ef59868aaa33341969c69aa6b6075 && git checkout -B drift-repro 490879ad0fe22c57d74799828a49feac6860757a && git merge --no-ff 1091f1e60a5ef59868aaa33341969c69aa6b6075
node scripts/docs-audit/affected-docs.mjs --json 490879ad0fe22c57d74799828a49feac6860757a
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32560345637 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10740
Three single-record (
multi: false) writes onsys_http_delivery/sys_notification_deliveryare audited under the driver'supdateop — a different op, and a different throttle key, from theupdateManyhalf. Their correct classifications are opposite, and treating them as one sweep is the dangerous reading this PR exists to avoid.SqlNotificationOutbox.ackdispatcherAckOptions(id)SqlHttpOutbox.ackdispatcherAckOptions(id)SqlHttpOutbox.redeliverPOST /api/v1/webhooks/redeliverThe two
acksites — warrant re-derived, not inheritedEach limb was re-checked against this tree rather than carried over:
ackhas exactly two callers —dispatcher.ts:210,240,249,286andhttp-dispatcher.ts:183,193— all insiderunPartition(), which runs off asetIntervaltick holding thenotify.dispatcher.partition.N/http.dispatcher.partition.Ncluster lock.ClaimOptions/HttpClaimOptionsare{nodeId, limit, partition, claimTtlMs, now}.hashPartitionis 32-bit FNV-1a overrefId | notificationId | digestKey,mod count— load-spreading, so a partition holds every organization's rows by construction.messaging-service-plugin.ts:282,310), so a per-org predicate would strand every other organization's deliveries.The helper is a new sibling, not a reuse:
dispatcherSweepOptionsreturns& { multi: true }, so these sites cannot borrow it — deliberately, and the file now says so instead of asserting thatackandredelivershare a classification.The helper's docstring also names the tempting wrong answer, because it is available here and it is worse than the flag: passing the claimed row's own
organization_idas the tenant is a predicate read off the row it is about to write. It matches exactly that row, excludes nothing, adds no isolation — and silences the audit, leaving the next reader looking at a write that appears scoped. The audit asks whether the caller's tenant reached the write; on a dispatcher tick the honest answer is that there is no caller tenant.redeliver— the site the audit was built forThe route in front of it authenticates and nothing more ("every authenticated user counts"), and
sys_http_deliveryis tenant-scoped. It now carries the caller's tenant, applied to the rows it reads as well as the row it writes, and the webhook route resolves the session'sactiveOrganizationIdto supply it.⛔ No
bypassTenantAuditis anywhere on this path. A scoped write and a bypassed write produce the same silence in the log, so the flag would have converted a detectable hole into an undetectable one — which is why the tests below do not assert the audit line alone.Scoping the reads is what makes the refusal fail-closed and quiet: a row in another organization is
RESOURCE_NOT_FOUND(HTTP 404), so the endpoint neither replays it nor confirms it exists.The contract change (why this stays draft,
needs:contract-review)redeliver(id, guard?)→redeliver(id, { tenantId, guard? }), andredeliverHttp(id)→redeliverHttp(id, { tenantId }).tenantIdis a required property typedstring | undefined: omitting it does not compile, and a genuinely tenant-less caller has to writetenantId: undefinedand mean it. An optional property would have let the dangerous case — a request path that simply forgot — type-check in silence, which is the shape this change exists to remove. It worked as intended on landing: the first typecheck failed at 10 call sites, each of which had to state an answer.Passing
undefinedleaves the write unscoped and the audit line still fires. That is deliberate reporting behaviour for a deployment that cannot resolve an organization for the caller, and it is pinned in both packages.Zero
packages/specownership. None was needed:tenantIdis already a declared engine passthrough key (ENGINE_DRIVER_PASSTHROUGH_KEYS, legal forfind/findOne/update/delete), so the tenant reachesDriverOptionsthrough the existing contract.Tests
packages/services/service-messaging/src/delivery-update-tenant-audit.integration.test.ts(6) — realSqlDriveron better-sqlite3, realsyncSchemas(),OS_TENANCY_POSTURE=isolatedread live, production outboxes and dispatchers.acksites — a realHttpDispatcher/NotificationDispatchertick, then a pin onstatusandattempts.ackonly runs once a delivery is actually processed, so an audit line absent because nothing ran is NOT MEASURED, not a pass.SqlDriver.update(the method that both callsauditMissingTenantand appliesapplyTenantScope) records the options that actually reached the driver:tenantId: 'org_a'present,bypassTenantAuditabsent. An assertion that only checked for the missing audit line would pass on the forbidden implementation.updateon the same object through the same driver, run last (the gate throttles one warning per${object}:${op}), which must produce the line.packages/plugins/plugin-webhooks/src/webhook-redeliver-tenant-scope.test.ts(4) — the half the service test cannot reach: that the tenant comes from the request, and the ADR-0112codeandstatus(RESOURCE_NOT_FOUND+ 404) on a cross-tenant refusal.codealone would pass on a refusal surfacing as a 500.Ablation — prediction written before mutating
Dropped the threaded tenant from
redeliver's write leg only, leaving the reads scoped.tenantIdassertion RED,undefinedvs'org_a'AssertionError: expected undefined to be 'org_a'tenant-less caller is NOT silencedtest, which pins the same directionacktests stay GREENRestore proved byte-identical:
git hash-objectreads41c5a0b96e6d06a39f6f3eb59f3f896039a2d903before the mutation and after the restore; the restore leg re-runs 6/6 green.srcvsdist, argued from the filesThe subjects (
SqlHttpOutbox,SqlNotificationOutbox, both dispatchers) are imported by relative specifier (./sql-http-outbox.js) from inside the samesrc/directory — a relative import cannot leavesrc/, and only bare package specifiers reach adist/throughexports.packages/services/service-messaging/distdid not exist at all when the ablation ran. The package's only vitest alias is@objectstack/core→ source, which touches none of these. Empirically: the mutation flipped the verdict with no rebuild. What does resolve throughdist/is@objectstack/objectqland@objectstack/driver-sql, which is why the dependency closure was built first.Gates
Union derived on the final commit
1091f1e60, clean tree,node scripts/pm/dispatch-gates.mjswith no path arguments (16 paths, identical set on re-derivation). Exit codes captured before any pipe. Every gate below reported its own verdict line green.check:changeset-gate-self-tests·check:objectui-changeset·check:route-envelope·check:slot-lookup·check:test-source-alias·check:type-source-resolution·check-adr-0087-registration·check-changeset-no-major·check-ci-filter-parity·check-empty-changeset·check-plugin-teardown-shape·check-affected-docs·check:query-options-erasure·check:type-check-coverage·check:type-check-debt·check:engine-double-contract·check:where-matcher·check:i18nPlus two the derivation did not name, run on judgment (class #10309 is live):
check:nul-bytes(any edit) andcheck:tenant-chokepoint(topic-adjacent — 20getBuilder()bindings across 3 files, every read builder routing throughapplyTenantScope()).Two gates needed repair before they measured anything, both recorded rather than smoothed over:
check-adr-0087-registrationfailed twice. First because the marker spelled the symbol half as a member path (IHttpOutbox.redeliver) rather than the bare exported identifierparseSymbolRefrequires; then because it reads changesets from git, not the working tree, so the corrected marker was invisible until committed. It then refusedMessagingServiceas unresolvable —packages/spec/src/api/protocol.zod.tsmentions the class name in a prose comment without declaring or importing it. The marker now names only the two symbols the gate can verify, and states theMessagingService.redeliverHttpclaim in prose where a reviewer reads it rather than where a checker would appear to have verified it.check:i18nfirst returnedPREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked— NOT MEASURED, not a pass. Built the CLI as the gate's own text prescribes and re-ran it: 9 packages, all bundles in sync.Package suites:
service-messaging25 files / 254 tests,plugin-webhooks11 files / 128 tests, both fully green, both typechecks clean.Found on the way, not fixed here
#11009 — a compare-and-set
whereon a by-idupdateis silently inert:SqlDriver.updatenever appliesoptions.where, so thestatus: { $in: [...] }guard inredeliver's own write does nothing. Measured (apendingrow'sattemptswent 7 → 0 under a predicate demanding a terminal status). It pre-dates this card, is a different defect class, and its fix is a contract decision onObjectQL.update— so it is filed unassigned and left untouched here. #11009 is not addressed by this PR.Not in scope, deliberately: cloud#1512's
crm_contracthalf.Generated by Claude Code