fix(service-storage): scope the sys_file / sys_upload_session update and delete doors to the acting organization (#13178) - #13527
Conversation
…and delete doors to the acting organization (#13178) The four un-repaired halves of the two inserts #12745 and #12928 fixed: `updateFile`, `deleteFile`, `updateSession` and `deleteSession` now take the same optional `StorageWriteContext` the create doors take, and the upload routes pass it -- two of them had already resolved the session and discarded it. On these verbs the context SCOPES rather than stamps (`applyTenantScope`, not `injectTenantOnInsert`), so another organization's row stops being reachable while `organization_id IS NULL` rows stay in reach -- which is what keeps the pre-#12928 population, deliberately not backfilled, from being stranded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 ebe64d74142ccdb69693b09753e016c030a0e0d2 && git checkout ebe64d74142ccdb69693b09753e016c030a0e0d2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57827b617e1f6f38a684b5b1daf56c8a7455cb81 e02648ef1b1d200f20079b602b7af735e61c2b73 && git checkout -B drift-repro 57827b617e1f6f38a684b5b1daf56c8a7455cb81 && git merge --no-ff e02648ef1b1d200f20079b602b7af735e61c2b73
node scripts/docs-audit/affected-docs.mjs --json 57827b617e1f6f38a684b5b1daf56c8a7455cb81
|
|
PM review — ACCEPT, with landing held until CI converges (25 success · 2 skipped · 6 still running · 0 red at time of writing; What I verified myself, on the diff and on
|
Fixes #13178
Repairs the four half-repairs the maintainer's 2026-08-30 ruling (第 5 场总监席决裁批 #7, verbatim 「批 #7 同意」) narrowed this card to: the
updateanddeletesiblings of the two inserts #12745 and #12928 already fixed. Nothing else in the census's 24 context-less call sites, and nothing in the 175, is touched.How the four sites were located
The ruling's own execution note forbids copying its line numbers, so they were re-derived against today's tree. Input: the census
os-dev-report(comment 5468253235), which names each site by file, verb and object — never relied on for a line number. Resolution, run in the worktree at merge basebcef1cfac:That enumerates all eight engine calls the store issues, which is what makes the pairing checkable rather than asserted: the two
insertcalls carry an options bag and the fourupdate/deletecalls did not.metadata-store.tsupdateFilesys_filemetadata-store.tsdeleteFilesys_filemetadata-store.tsupdateSessionsys_upload_sessionmetadata-store.tsdeleteSessionsys_upload_sessionAll four were still unfixed at merge base — verified by that enumeration, not assumed from the census.
What #12745 / #12928 did, and how this matches
Both landed the same three-part shape (
c94be6291d,2ff01cf7fa): an optionalStorageWriteContextparameter on the store method;writeOptionsFor(context)folded onto the engine options bag; and the acting organization threaded at the route. This PR is that shape on the remaining four verbs — same helper, same{ context: { tenantId } }spelling, same "absent means absent" rule where a caller with no organization produces the byte-identical pre-repair call.One thing genuinely differs, and it is documented rather than glossed. Write-side tenancy in the SQL driver is two mechanisms wearing one option: on insert
injectTenantOnInsertSTAMPS the tenant column fromoptions.tenantId; on update and deleteapplyTenantScopeSCOPES the statement with it. So the same bag buys a value on one verb and a reach on the other. Consequences, both measured in the new suite:organization_id IS NULLrow stays reachable, because the driver's term is(organization_id = :tenantId OR organization_id IS NULL)— Freshobjectstack devboot: tenant admin sees ZERO rows in sys_position / sys_permission_set / sys_business_unit over REST (Setup Access Control renders empty) #2734's global-row fail-open. That is load-bearing here: multi-org:sys_upload_sessionis never org-stamped either —StorageMetadataStore.createSessioninserts without a context #12928 was ruled forward-stamp-only, so the entire pre-multi-org:sys_upload_sessionis never org-stamped either —StorageMetadataStore.createSessioninserts without a context #12928 session population is NULL by decision and strict equality would have stranded it mid-upload.Because the meaning differs, the engine-absent
Mapstand-in is deliberately NOT changed.createFile's stand-in records a value so a later read can return it; there is no value to record on these verbs, only a reach to enforce, and enforcing a tenant wall over a process-local Map would stand up a second isolation mechanism outside the driver that owns the real one.Scope fences, discharged
auditMissingTenantis untouched —packages/drivers/driver-sql/**has no diff in this PR at all. The reading thatisSystemwrites return at thebypassTenantAuditguard one step before the posture gate is the class-level diagnosis, and it is not acted on here.isSystemwrites belong in this control scope at all is a question this PR neither answers nor leans on.packages/specchange, no ADR, no release notes, no test skipped or re-baselined.Paths touched, declared in full
packages/services/service-storage/src/metadata-store.tspackages/services/service-storage/src/storage-routes.tspackages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.tsscripts/engine-double-contract.pinned.jsonNew pinned coverage is GOOD … the ledger just has to learn about it); written with--write,0 lost.changeset/tenant-audit-update-delete-half-repairs.mdminor— argued in the changeset bodyDocs-drift advisory — read, and NOT falsified
The drift run flags
content/docs/permissions/attachments-access.mdxas carrying bothsys_fileandsys_upload_session. I read the page in full (141 lines). It is accurate as written and nothing was changed.Its three subjects are the
sys_attachmentparent-derived access model (create / read / delete on the join row), the download gate, and the ADR-0057 storage-byte lifecycle. This PR touches none of them: nosys_attachmentpath, no download endpoint, no reap guard. The two doors it changes are upload-side writes on the metadata rows.The decisive reading is an absence, so it is measured rather than asserted:
grep -icE "organization|tenant|multi-tenant|org "over that page returns 1, and the single hit is the phrase "org logos" in a list of non-attachments file examples. The page makes no tenancy claim at all, so there is nothing on it for an organization-scoping change to contradict. Its nearest sentence — the upload entry points "stampowner_idon the newsys_file" — is about the insert and aboutowner_id, both untouched.The four release-owned pages in the same advisory were read but not edited (they are read-only in a code PR). A targeted cross-grep for any claim pairing these two tables with organization or tenant scoping returns zero rows across
content/docs/releases/*.mdx. The one v17 line naming asys_file/sys_upload_sessionwrite is the #5216 durability correction ("a write that never landed stops reporting success"), which this PR does not touch. No release page is wrong, so there is nothing to file as a docs-only card.Verification
Union re-run after the final commit, at
e02648ef1b. Every heavy run went throughscripts/pm/os-verify-lock.sh; the verdicts quoted are the lines that entry point printed, never a bare$?behind a pipe.pnpm --filter @objectstack/service-storage test— 32 files, 506 tests, all pass (21 of them new).tsc --noEmit --pretty false -p packages/services/service-storage/tsconfig.json— 51 errors, exactly thecheck-type-check-coverageledger entry, and 0 of them in any of the three touched files. That entry's note says the margin is gone and the next new error reds on arrival, so this is the load-bearing number.--listFilesconfirms all three files are inside the program — the count is a measurement of them, not a program that excludes them.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(change set taken from the merge base by the script itself). Green:engine-double-contract,where-matcher,objectql-double-limit,query-options-erasure,type-check-coverage,cross-package-test-inputs(both invocations),test-source-alias,type-source-resolution,route-envelope,undeclared-dep-imports,logger-receiver-detach,nul-bytes,i18n,i18n-stale-fill,doc-authoring,published-files,slot-lookup,page-declaration-shape,ci-filter-parity,comment-mask-adoption,keyed-text-bounds,plugin-teardown-shape,shard-attestation,changeset-gate-self-tests,objectui-changeset,pm-half-states,adr-0087-registration,changeset-no-major,empty-changeset,release-rehearsal-clone,docs-audit/affected-docs.pnpm lint(eslint . --no-inline-config, repo-wide) — exit 0, no findings, held the lock 67s.pnpm check:i18n— OK, 9 packages, all bundles in sync,service-storageamong them, after its ten-package build closure.engine-double-contract(ledger taught the new pins) andquery-options-erasure(afindOnequery bag was erased toany; typed asDriverQueryinstead, test surface back to 240 at the ceiling).Ablation
Implementation committed first; the mutation stripped
...optionsfrom all four sites, under anEXIT INT TERMtrap with an absolute repo root../metadata-store.js), so the suite reads it from source.@objectstack/driver-sqlis source-aliased by this package's vitest config; nothing mutated resolves throughdist/.git diff HEADempty,git status --porcelainempty over the whole tree.Not measured — stated as not-measured, not folded into green
check:type-check-debt(the ratchet invocation) was not run — it re-runs tsc for every ledger entry and needs the whole workspace closure built, which does not fit the container's foreground ceiling under lock contention. Declared narrowing: the only ledger entry a three-fileservice-storagediff can move was measured directly, with the gate's own invocation shape, and is the 51 above. The rest of the ledger has no path in this diff.check:dual-build-cjs-loadsneeds the Build Core artifacts; left to CI.check-test-completenessandscripts/pm/check-half-states.mjsboth exited with their ownPREREQUISITE NOT METcode (3) — no test log, no token. Neither is a finding.ObjectQLprovescontext.tenantIdreachesDriverOptions.tenantIdon both verbs, and a realSqlDriveron live SQLite proves what that option then does. A single full-stack fixture would be stronger and is not here.organization_idlives in the out-of-tree enterprise middleware, so "the write now carries its tenant" is a statement about the call site.Authored by Claude Code, session
session_01F3jdziLbAPGeceVNmSox5L(recorded in prose because a body edit normalises the footer link).Generated by Claude Code
Generated by Claude Code