Repository navigation
Commit a7a48b7
fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in (#22045)
Part of #21908
Clause-②: no
Phase two, stage 2a of the principal-less hand-off closure (ADR-0096 E1
/ D5), per the maintainer's ruling on the card (letter A). Seven more
producers of a principal-less, non-system data context now take the
explicit system opt-in (`isSystem: true`) inside their owning service.
The deny itself lands last and is not in this PR, so #21908 stays open.
⛔ No `plugin-security`, `packages/spec`, `metadata-protocol` or
`docs/adr` file is in the diff, there is no new elevation API, and no
door's authorization changed.
## What moved
Positions are at the base `9a0401fdd3`. "Gate fired" means one of the
six gates the security middleware still runs before its hand-off
(package-managed, system-row, curated-capability, audience-anchor,
ADR-0103 engine-owned, ADR-0090 D12 delegated-admin) fires on the
producer's calls. Counts are principal-less calls at the hand-off,
before and after the change; every "after" call arrived as an `isSystem`
call.
| Position | Function | Doors | Door authorizes (where) | Gate fired |
Moved | Evidence (harness · dogfood subset) |
| :-- | :-- | :-- | :-- | :-- | :-- | :-- |
| `service-storage` `metadata-store.ts:373` |
`StorageMetadataStore.getFile` | upload commit
(`storage-routes.ts:453`), both download doors (`:799`, `:846`), and the
chunked completion through `updateFile` | yes. Commit and chunked
completion: session authentication (`requireUploadSession`, `:458`,
`:684`) before the read. Downloads: `authorizeDownload` (`:808`, `:855`)
after the read and before anything is disclosed, because its verdict
reads the row | no | yes | 5 → 0 · 111 → 0 |
| `metadata-store.ts:399` | `StorageMetadataStore.updateFile` | upload
commit (`:472`), chunked completion (`:720`) | yes. Session
authentication before the write; the session's organization scopes the
write | no | yes | 2 → 0 · 46 → 0 |
| `metadata-store.ts:434` | `StorageMetadataStore.deleteFile` | none. No
production caller in this repository; the class is exported | not
applicable, no door | no | yes | 1 → 0 (direct call) · not reached |
| `metadata-store.ts:502` | `StorageMetadataStore.getSession` | chunk
upload (`:593`), chunked completion (`:681`), progress (`:751`) | yes.
Session authentication before the read; at the chunk door the
resume-token check (`:617`) follows the read, because it reads the row's
token, and precedes every write | no | yes | 8 → 0 · 2 → 0 |
| `metadata-store.ts:529` | `StorageMetadataStore.updateSession` | chunk
upload (`:658`, expiry via `:623`), chunked completion (`:705`, `:725`,
expiry via `:694`, failure stamp via `:732`), progress (expiry via
`:770`) | yes. Session authentication before every write; the chunk door
also checks the resume token first | no | yes | 4 → 0 · 1 → 0 |
| `metadata-store.ts:562` | `StorageMetadataStore.deleteSession` | none.
No production caller in this repository | not applicable, no door | no |
yes | 1 → 0 (direct call) · not reached |
| `service-messaging` `sql-http-outbox.ts:468` |
`SqlHttpOutbox.redeliver` (two reads, one reset write) | `POST
/api/v1/webhooks/redeliver` (`plugin-webhooks`
`webhook-outbox-plugin.ts:397`) through `MessagingService.redeliverHttp`
| yes. An authenticated session or `401` before the call; the session's
active organization is the tenant; the producer's veto runs before the
write | no | yes | 3 → 0 · not reached |
Read scope, asked for by the claim: `getFile` and `getSession` read by
id with no organization scope before the move (no context at all, so no
`tenantId` reached the driver, and the middleware handed the call
through before any row or tenant filter) and after it (the middleware
short-circuits before the same filters, and still no `tenantId` reaches
the driver).
## What the opt-in would also have changed, and what holds it
The opt-in skips more than the six gates. Measured on the real engine,
per call:
- **The update-side `readonly` strip.** `updateFile` and `updateSession`
used to send the whole row read back, merged with the patch. Under the
strip, the engine took `organization_id`, `created_at`, `updated_at`,
`created_by` and `updated_by` out of that row on every call. Under the
opt-in the strip does not run, so the full row, `organization_id`
included and read without tenant scope, would have been written back.
**Fix inside the store:** the two updates now send the caller's patch
alone (`changedColumns`). Measured on SQLite through the real engine: on
the base the strip took those five columns; on the change it takes none,
and the stored rows are equal on every non-timestamp column, with
`updated_at` still stamped by the platform.
- **The tenant-audit fill-in.** For an `isSystem` write,
`ObjectQL.buildDriverOptions` fills in `bypassTenantAudit: true` when
the object is outside the platform tenancy inventory. `sys_file` and
`sys_upload_session` are in it as tenant-scoped, so nothing changes for
them (pinned). `sys_http_delivery` is not, so the opt-in would have
silenced the audit for a redelivery from a caller with no organization,
which is the one line `RedeliverOptions` keeps. **Fix inside the
outbox:** the reset write states `bypassTenantAudit: false`. The engine
never overwrites an explicit value, and the driver still audits.
- **Unchanged, measured or read:** row and field security, the masker
and the Layer 0 tenant wall are skipped by the hand-off and by the
opt-in alike. Read auditing records neither (no user id). No lookup
field exists on the three objects, so the referential-integrity skip
does not apply. Redelivery's bulk data event publishes without an
organization in both cases.
## Pins
- **Store, `tenant-audit-update-delete-half-repairs.test.ts`.**
- Every by-id read carries `{ context: { isSystem: true } }` and no
tenant.
- Every by-id write carries the opt-in beside the door's tenant, or the
opt-in alone with no tenant invented. The route-level pins are updated
to match.
- ⛔ An update payload never carries the provisioned columns of the row
read back.
- The opt-in leaves these writes' tenant audit armed.
- ⛔ Negative, through the real `ObjectQL` over a real `SqlDriver` on
SQLite in the isolated posture: under the opt-in, a door tenant's
`updateFile`, `updateSession`, `deleteFile` and `deleteSession` on a row
stamped for another organization are refused
(`StorageMetadataStoreError` wrapping `RECORD_NOT_FOUND`) and leave the
row untouched. The still-works half shows the caller's own row and an
organization-less row are reached.
- **Outbox, `system-context.pin.test.ts`.** Both reads and the reset
write carry the opt-in. The caller's `tenantId` stays on every bag. The
reset states `bypassTenantAudit: false`, and a caller with no tenant
gets none invented.
- **Outbox, `delivery-update-tenant-audit.integration.test.ts`.**
- ⛔ Negative, on the real driver: a foreign row stays
`RESOURCE_NOT_FOUND` with zero writes under the opt-in, and the caller's
own row resets.
- The two existing audit assertions now read `false`, the value the
driver receives, where they read "absent".
- **The stage-1 pins stay green.** These are #22025's insert pins and
#22037's inbox pins, inside the full package suites.
**Ablations**, run at `0f6df0f306`. Each mutation went through
`scripts/ablation-replace.mjs`: the anchor hit, the blob changed, and
the restore was proved by blob equal to `HEAD` and an empty `git diff
HEAD`. The pins import from `src`, so no `dist` leg applies.
| # | Mutation | Result |
| :-- | :-- | :-- |
| A1 | by-id writes keep the opt-in but drop the door tenant | 13 of 28
red; the negative pins read "promise resolved … instead of rejecting"
(the foreign row was reached) |
| A3 | `updateFile` sends the merged row again | 1 of 28 red; the
payload carried `organization_id` and `created_by` |
| A4 | `redeliver`'s deciding read drops `tenantId` | 5 of 30 red; the
negative pin got `DELIVERY_NOT_ELIGIBLE` where it expects
`RESOURCE_NOT_FOUND` |
| A5 | the reset write's `bypassTenantAudit: false` removed | 4 of 21
red; the driver received `true` ("expected true to be false"), and the
tenant-less redelivery went silent |
## Measurement
The measurement used a local instrument in `plugin-security`'s
middleware, never committed. Its instrument file was reverted, and blob
`5b4ab28045af` equals `HEAD`. `plugin-security` was rebuilt, and
`ablation-dist-preflight --absent` passes; its positive control found
the marker in 2 built files while it was live.
For each call from the seven producers, the instrument recorded the
producer frame, whether the call was principal-less, and a dry run of
the six gates with the system flag cleared. Two runs used it:
- A scratch harness, deleted and not in the diff. It drove every door on
a booted stack: upload commit, both downloads, the chunk upload,
progress, chunked completion, progress on an expired session, and `POST
/api/v1/webhooks/redeliver`. It called `deleteFile` and `deleteSession`
directly.
- The 11-file dogfood subset named below.
Every door answered the same on the base and on the change (200, or 302
for the redirect). There were 0 gate firings before and after.
## Acceptance notes
- **Premise correction.** `updateFile` and `updateSession` already
accepted the optional organization context (`metadata-store.ts:399`,
`:529`), as `deleteFile` and `deleteSession` did. Only `getFile` and
`getSession` take none, and they still take none: the reads carry the
opt-in without a tenant. No public signature changed.
- **Door ordering, reported for the seat.** At both download doors the
read precedes `authorizeDownload`. At the chunk door the read precedes
the resume-token check. Each check reads the row it needs, and each
precedes any disclosure and any write. The read's reach is the same
before and after (unscoped by principal and by organization).
- **Doors without a door-level binding.** The chunked completion and
progress doors authorize by session authentication and check no resume
token. The commit door checks no ownership of the file id. The move
leaves all three unchanged, and owner checks are the question the ruling
sent to its own card (option C). Reported to the seat at class level.
- **`deleteFile` and `deleteSession` have no caller in this
repository.** They were moved as the ruling wires all seven. Their door
check is vacuous.
- **Files beyond the two named files, all in the same packages.**
- `outbox-dispatcher-scope.ts` declares `REDELIVER_SYSTEM_CONTEXT`
(module-internal) with its own warrant, and amends the two docblocks
that kept the outbox opt-ins off `redeliver`.
- `messaging-service.ts` gets one sentence made true.
- `scripts/engine-double-contract.pinned.json` counts the storage test
file's second double, which `--write` grew.
- `plugin-audit`'s `audit-writers.test.ts` fixture prose still describes
`updateSession` writing the merged full record. That is test residue in
another package and is left alone.
## Verification (head `2665532dc9`)
Each command ran in the foreground of this worktree, and its exit code
was captured before any pipe.
- **Gates.** Running `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `2665532dc9`, with no paths,
derived 73 commands. The dispatch-time 51 are a subset. All 73 ran and
all exited 0. The reconciliation (`--ran`) printed: "73 derived
famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all
73 recorded an exit code and none of them is 3)".
- On an earlier pass at `6f366adcb1`, `check:engine-double-contract`
exited 1. It had counted the new recording wrapper as a fake engine. The
wrapper now routes each verb through the engine's dispatch predicates,
and `--write` grew the ledger.
- `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: 8
packages outside the dogfood closure had no `dist/`). They were built,
and it then exited 0.
- **Suites at `2665532dc9`.** `@objectstack/service-storage`: 42 files,
659 tests passed. `@objectstack/service-messaging`: 48 files, 534
passed. `typecheck` passed for both, the storage test layer's
`check:test-typecheck` included. `--listFiles` confirms both programs
compile the edited test files. `@objectstack/plugin-webhooks`, which
mounts the redeliver door, passed at `0f6df0f306`: 15 files, 165 tests.
- **Dogfood.** The 11 files below passed, 100 passed and 1 skipped, on
both the base and the change. They ran against the change's built
`dist/`; the source is unchanged since `230ed9ea15`. The files:
`attachments-permission-matrix`, `attachments-public-read-acl`,
`attachments-unscoped-delete-gate`, `field-file-collection`,
`file-field-constraint-refusal`, `sys-file-metadata-write-refusal`,
`predicate-write-unreadable-not-matched`,
`write-door-unreadable-is-not-found`, `storage-growth`,
`temporal-storage-e2e`, `webhook-materialization`. None of these files
reaches the redeliver door; the scratch harness covered it.
- **Lint, a proven narrowing; CI runs the full `pnpm lint`.** The
population was read from `eslint.config.mjs`: the
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block plus the `packages/**`
blocks. `eslint --no-inline-config --format json` over the 7 changed
`.ts` files reported 7 files, 0 errors and 0 warnings at `c4f10218af`.
The config enables no type-aware linting (no `parserOptions.project` and
no typed rules), so no untouched file's verdict can move.
## Changeset
`.changeset/21908-by-id-producers-opt-in.md` grades `patch` for
`@objectstack/service-storage` and `@objectstack/service-messaging`. It
names the opt-in, the patch-only update payload and the held tenant
audit. No exported type or entry symbol changed: the new constants and
the store helper are module-internal.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c565813 commit a7a48b7
9 files changed
Lines changed: 548 additions & 42 deletions
File tree
- .changeset
- packages/services
- service-messaging/src
- service-storage/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | | - | |
| 73 | + | |
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
| |||
325 | 325 | | |
326 | 326 | | |
327 | 327 | | |
328 | | - | |
329 | | - | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
330 | 333 | | |
331 | 334 | | |
332 | 335 | | |
| |||
373 | 376 | | |
374 | 377 | | |
375 | 378 | | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
376 | 420 | | |
377 | 421 | | |
378 | 422 | | |
| |||
389 | 433 | | |
390 | 434 | | |
391 | 435 | | |
392 | | - | |
| 436 | + | |
| 437 | + | |
393 | 438 | | |
394 | 439 | | |
395 | 440 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
358 | 358 | | |
359 | 359 | | |
360 | 360 | | |
361 | | - | |
| 361 | + | |
362 | 362 | | |
| 363 | + | |
| 364 | + | |
363 | 365 | | |
364 | 366 | | |
365 | 367 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
99 | 101 | | |
100 | 102 | | |
101 | 103 | | |
| |||
117 | 119 | | |
118 | 120 | | |
119 | 121 | | |
| 122 | + | |
| 123 | + | |
120 | 124 | | |
121 | 125 | | |
122 | 126 | | |
123 | 127 | | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
124 | 160 | | |
125 | 161 | | |
126 | 162 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| 11 | + | |
10 | 12 | | |
11 | 13 | | |
12 | 14 | | |
| |||
464 | 466 | | |
465 | 467 | | |
466 | 468 | | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
467 | 489 | | |
468 | 490 | | |
469 | 491 | | |
| |||
473 | 495 | | |
474 | 496 | | |
475 | 497 | | |
476 | | - | |
| 498 | + | |
477 | 499 | | |
478 | 500 | | |
479 | 501 | | |
| |||
493 | 515 | | |
494 | 516 | | |
495 | 517 | | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
496 | 528 | | |
497 | 529 | | |
498 | 530 | | |
| |||
506 | 538 | | |
507 | 539 | | |
508 | 540 | | |
509 | | - | |
| 541 | + | |
510 | 542 | | |
511 | 543 | | |
512 | 544 | | |
513 | 545 | | |
514 | | - | |
| 546 | + | |
515 | 547 | | |
516 | 548 | | |
517 | 549 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
420 | 420 | | |
421 | 421 | | |
422 | 422 | | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
0 commit comments