Skip to content

feat(service-storage): the file-attachments service, a server-side verb that stores bytes and attaches them to a record - #23386

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-23306-storage-attach-verb
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-23306-storage-attach-verb

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23306
Clause-②: yes

A server-side producer (inbound mail first, then imports and generated output) can now store bytes and attach them to a record. StorageServicePlugin registers the kernel service file-attachments (FILE_ATTACHMENT_SERVICE), declared in @objectstack/spec/contracts as IFileAttachmentService. Its one verb, attachFile({ parentObject, parentId, organizationId, filename, mimeType, data }), writes the bytes, one sys_file row and one sys_attachment row linking it to the parent, and answers { fileId, attachmentId, size }. The widening is additive: four new exports from @objectstack/spec/contracts (IFileAttachmentService, AttachFileInput, AttachedFile, FILE_ATTACHMENT_SERVICE) and one more service in StorageServicePlugin.providesServices. Nothing is removed or renamed.

One writer: each check, and where it comes from

The triage direction is "one writer, no second copy". Each check the verb runs is the code every other attachment of the parent already meets. Measured on a real ObjectQL over sqlite :memory: with the plugin's own hooks and plugin-audit's real writers installed:

check where it comes from under the verb's system context
upload size limit uploadSizeRefusal in storage-limits.ts: the verdict the REST upload doors ask, against the same resolveStorageLimits over the storage service's Limits snapshot runs, 413 PAYLOAD_TOO_LARGE, before anything is stored
the sys_file row and its organization stamp StorageMetadataStore.createFile / updateFile / deleteFile, the upload doors' writer, through the same StorageWriteContext channel runs
enable.files opt-in plugin-audit's enforceFilesCapability, a beforeInsert hook on sys_attachment runs: 403 FILES_DISABLED (measured, and pinned with the real gate)
parent EDIT (installAttachmentAccessHooks) the sys_attachment beforeInsert hook in this package returns at its system-context line by its own declared design; checkEdit was called 0 times (measured). A server-side producer has no caller whose edit access could be asked.
attachment lifecycle installAttachmentLifecycleHooks afterInsert runs (a pending file is not a tombstone, so it changes nothing)
the parent's organization the parent read by id as the system; its organization from createWallOrganizationResolver, the package's one answer to "which column is this record walled by" a parent walled into another organization than organizationId is 404 RECORD_NOT_FOUND, the same answer as a missing parent

Both rows are written in the parent's organization when it has one, otherwise in the caller's. organizationId is required and nullable: acting in no organization is stated, never a left-out field.

Mechanism hypotheses, measured

  • H1 holds, with one correction. The upload door's "attach" is a data-API insert of sys_attachment judged by beforeInsert hooks, so the one writer is an engine insert, not a function in storage-routes.ts. Under the system context the parent-EDIT gate does not run (above). The three refusals the card names do run.
  • H2 holds, and "quota" is the per-upload limit. No per-organization aggregate storage quota exists in open code (git grep -i for a storage quota concept: 0 hits; the only quota code is SMS, lifecycle row counts and AI). The byte-acceptance limit is max_upload_mb. It was enforced by requireWithinUploadLimit, a closure inside registerStorageFamily (storage-routes.ts) with the refusal constants beside it. The extraction is a move: the status, the code, the sentence and the comparison now live in storage-limits.ts (uploadSizeRefusal, UPLOAD_TOO_LARGE_STATUS, UPLOAD_TOO_LARGE_CODE), beside the limit; the doors call it, and so does the verb. The doors answer byte-for-byte as before (their 18 limit pins stay green). main was merged before this PR opened (PR fix(types): one caught-fault writer, sendServerFault; four route families' catch-all 5xx withhold the caught error's text #23346 landed in the same file; its hunks and these are disjoint).
  • H3: a sibling contract, not a member of IStorageService. IStorageService is the blob store an adapter implements (LocalStorageAdapter, S3StorageAdapter, third parties), and the storage slot's occupant can be a bare adapter with no data engine. A row-writing verb there would be an optional member most implementors could never honour. The sibling follows the existing non-core slot convention (SEED_SETTLEMENT_SERVICE, SHARE_LINK_SERVICE): an interface plus a slot constant, in storage-service.ts beside IFileAccessDelegate.
  • H4: no refusal leaves a committed sys_file, by write order. The order is the presigned door's: the sys_file row first, pending, then the bytes, so the row is always the pointer the pending-upload retention and the reap guard reclaim the bytes by, even if the process dies mid-call. The row turns committed only in the same engine.transaction as the sys_attachment insert. A refused call then removes the bytes and the pending row before it rethrows. On an engine with no transaction, or inside a caller's transaction the verb joined, the commit never runs after a refused insert, and the verb removes an attachment row it did insert. attachment-refused-attach-tombstone.test.ts was read first: its tombstoner is for a user's committed upload; this verb never leaves one, so it does not need it.

The verb is armed at kernel:bootstrapped, after every plugin's kernel:ready handler has installed its gates (AuditPlugin installs the enable.files gate from its own kernel:ready). Before that, or with no data engine, it refuses 503 SERVICE_UNAVAILABLE rather than write a row a gate has not judged.

Tests

packages/services/service-storage/src/file-attachment-service.test.ts, 17 tests, through the plugin's own wiring: init, start, kernel:ready, kernel:bootstrapped, the service resolved by FILE_ATTACHMENT_SERVICE, a settings namespace carrying max_upload_mb, the plugin's local adapter in a temp directory, and plugin-audit's real gate. @objectstack/plugin-audit is a new devDependency, aliased to source in vitest.config.ts like core and driver-sql.

  • Success: one committed sys_file (attachments scope, private, key attachments/ plus the file id plus the extension, bytes behind it) and one sys_attachment, both in the parent's organization.
  • The three refusals the card lists, each asserting code and status and that NOTHING is left (no sys_file committed or pending, no sys_attachment, no stored key), each beside its control (the same call without the refused condition succeeds):
    • over the limit: 413 PAYLOAD_TOO_LARGE; control: exactly at the limit;
    • enable.files off: 403 FILES_DISABLED; control: an opted-in object;
    • parent in another organization: 404 RECORD_NOT_FOUND; control: the same parent from its own organization. A missing parent answers the same.
  • Organization stamping: null takes the parent's; a global parent takes the caller's.
  • A refused commit after the insert rolls the attachment back; on an engine with no transaction the verb removes the row itself; an upload failure removes the pending row and propagates unchanged.
  • 503 before kernel:bootstrapped and with no engine; 400 VALIDATION_ERROR for seven malformed inputs.

packages/spec/src/contracts/storage-service.test.ts: the slot name and the contract's shape.

Ablations (one-off, each through scripts/ablation-replace.mjs in WRAP mode)

Every leg: the anchor hit once and moved 1 to 0, the blob changed on disk, and the restore was proven (blob equals HEAD, git diff HEAD empty). The suite imports the mutated modules from source (relative imports, and plugin-audit through its source alias), so no rebuild sits between mutation and measurement. Run at 00e7f3217; the five mutated files are byte-identical at the final head ac4fcfdaf (git diff empty across the merge).

leg mutation prediction observed
A1 the verb ignores the size verdict 413 pin red 1 red (413 pin)
A2 a refused call cleans nothing up the leave-nothing pins red 4 red (FILES_DISABLED, commit rollback, no-transaction, upload failure)
A2b the row is written committed up front green: the cleanup still removes it 17 green, as predicted. The crash-window half of pending-first (process death mid-call) is not pinned.
A3 no organization wall 404 pin red 1 red
A4 no transaction, claimed owned rollback pins red 2 red
A5 no compensation outside an owned transaction no-transaction pin red 1 red
A6 armed at kernel:ready the 503-until-bootstrapped pin red 1 red
A7 organizationId not validated validation pin red 1 red
A8 the shared uploadSizeRefusal admits everything the verb's pin AND the doors' pins red verb 1 red; storage-limits.test.ts 12 red: one verdict for both
A10 plugin-audit's enable.files gate never refuses FILES_DISABLED pin red 1 red: the verb does not bypass the gate

The first attempts at A2 and A6 were refused by the tool as no-ops (the replacement contained the anchor, so the anchor count did not drop) and were re-anchored; the observed rows above are the re-anchored runs.

Readings (final head ac4fcfdaf, after merging main)

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2: 55 files, 956 tests passed.
  • pnpm --filter @objectstack/service-storage typecheck: exit 0; the test program compiles all 55 test files, 0 debt.
  • pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/contracts: 48 files, 453 tests passed. pnpm --filter @objectstack/spec typecheck: exit 0, test-typecheck ledger unchanged.
  • pnpm --filter @objectstack/spec check:generated: 14 of 14 artifacts current, built from this tree. The contracts shards of api-surface/ and export-origins/ gained exactly the four exports.
  • Narrowing, declared: the full @objectstack/spec local suite ran past the 10-minute foreground cap and was not measured locally; src/contracts (the changed directory) was. turbo ls --affected names 82 packages (the package graph only: it is blind to tests that read another package's source from disk, and root-level suites are not on it). The spec change is additive (0 removed exports), no package re-exports @objectstack/spec/contracts wholesale, and the four names have 0 other declarations in the tree, so no downstream verdict can move through it. CI runs the farm.
  • ESLint, narrowed and measured: eslint --no-inline-config --format json over the 8 changed .ts files gives 8 files, 0 errors, 0 warnings, 0 fatal. Population: eslint.config.mjs files: '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'; the rest of the diff is md, mdx, json and yaml. Invariance: the config enables no type-aware linting (no parserOptions.project), so no untouched file's verdict can move.
  • Built output: require of dist/index.cjs and import of dist/index.js both load and list the new service.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands (no paths) derives 124 families at ac4fcfdaf; 120 ran with exit 0, and --ran reconciles 124 of 124 with 0 UNRUN. NOT MEASURED, 4: check:skill-examples, check:dual-build-cjs-loads and check:i18n refused with their own PREREQUISITE NOT MET (exit 3: packages this diff does not touch are not built here; the changed package's built CJS and ESM entries were probed directly, above); check:type-check-debt was not run (its --re-measure is a repo-wide tsc sweep; this diff adds no package and moves no ledger entry). check:tenant-audit-census is green on the re-derived census.

Paths beyond the claimed surface (each forced by a named gate or by the tests)

  • content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md: check:tenant-audit-census re-derives the write-call-site census; the verb adds two sys_attachment writes, both carrying a tenant context. Regenerated with node scripts/tenant-audit-census.mjs --write, with the page's enforced prose figures moved with it (on the merged tree: 226 to 228).
  • packages/services/service-storage/package.json, vitest.config.ts and pnpm-lock.yaml: @objectstack/plugin-audit as a test-only dependency. The lockfile change is the three-line importer entry, accepted by pnpm install --frozen-lockfile; a plain pnpm install also re-resolved an unrelated vitest peer variant, which is not in this diff.

Acceptance notes


Generated by Claude Code

…he files-capability gate the attach verb's pins run through

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…s service that stores bytes and attaches them to a record

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…als and the no-committed-file invariant

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…shards for the four attach-verb exports

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…he attach verb's two sys_attachment writes

Both carry a tenant context; 225 to 227 sites, regenerated by scripts/tenant-audit-census.mjs --write with
the page's enforced prose figures moved with it.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
Takes main's side of the tenant-audit census page and ledger; they are re-derived in the next commit.

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
…26 + the attach verb's 2 = 228 sites)

Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-storage, @objectstack/spec, touching 45 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/services/service-storage/vitest.config.ts, packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6eb6f7d65b11572c5eca0ece12793438c101af90.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/services/service-storage/vitest.config.ts, packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 144 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb6f7d65b11572c5eca0ece12793438c101af90 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 238eaac902e80f610d83a1072c5b5cd7b68fe731 — the merge of head 362929d9e524332a7d52b7f7d193f59e0a45685b into base 6eb6f7d65b11572c5eca0ece12793438c101af90, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 238eaac902e80f610d83a1072c5b5cd7b68fe731 && git checkout 238eaac902e80f610d83a1072c5b5cd7b68fe731
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eb6f7d65b11572c5eca0ece12793438c101af90 362929d9e524332a7d52b7f7d193f59e0a45685b && git checkout -B drift-repro 6eb6f7d65b11572c5eca0ece12793438c101af90 && git merge --no-ff 362929d9e524332a7d52b7f7d193f59e0a45685b

node scripts/docs-audit/affected-docs.mjs --json 6eb6f7d65b11572c5eca0ece12793438c101af90

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb6f7d65b11572c5eca0ece12793438c101af90 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ac4fcfdaf67ece3b38fa2102d9aaf7f4eba1b498
Local-runs: none

Inputs read: card #23306 (body; triage 6151767880; claim 6158175677; os-dev-report 6160278988), PR #23386 (body, 15-file list, net diff against main at the head, +1053 −66), the check-runs on the head (read once, about 2026-10-14T03:39Z, not polled), and the committed source at the head and at origin/main by git show / git grep only.

① Derived judgments

Public-surface changes the diff implies — each named.

  • @objectstack/spec/contracts gains exactly four exports: IFileAttachmentService (interface), AttachFileInput (interface), AttachedFile (interface), FILE_ATTACHMENT_SERVICE (const, 'file-attachments'). RIGHT: the contracts shards of api-surface/ and export-origins/ carry exactly those four rows and nothing else; 0 removed, 0 renamed. Interface plus slot constant only, no runtime logic in spec (Prime Directive ✨ Set up Copilot instructions #2). data: Buffer matches IStorageService.upload's existing Buffer in the same file. A sibling slot rather than a member of IStorageService is RIGHT: that contract is the adapters' blob store, and an adapter holds no data engine to write rows through.
  • @objectstack/service-storage registers one more kernel service, file-attachments, declared in providesServices and registered in init() on every path with every dependency read per call (getEngine / getStorage / getLimitsSnapshot), armed at kernel:bootstrapped. RIGHT: both ObjectKernel (kernel.ts, phase 3.5) and LiteKernel (lite-kernel.ts) fire kernel:bootstrapped after every kernel:ready handler, and gatedEngine is assigned only inside the branch where this plugin installed its own sys_attachment hooks, so a kernel with no hook-capable engine keeps the verb at 503. buildKey and the three new storage-limits.ts exports are package-internal: index.ts re-exports neither (storage-limits contributes types only), so the package's published widening is the one service.

The verb's refusals and their envelopes — each named.

  • 400 VALIDATION_ERROR (standard code): field-by-field, before any read or write; organizationId is required and nullable, so "no organization" is stated. RIGHT.
  • 503 SERVICE_UNAVAILABLE (standard): before kernel:bootstrapped or with no engine, before any write. RIGHT, and the right direction (refuse rather than write a row no gate judged).
  • 413 PAYLOAD_TOO_LARGE (ADR-0112 ledger, @objectstack/service-storage owner key): the shared uploadSizeRefusal verdict, before a byte or a row. RIGHT.
  • 404 RECORD_NOT_FOUND (standard): parent missing, or walled into another organization than organizationId; one sentence for both, so the answer says nothing about the other organization. RIGHT. Named, not a defect: the upload door answers the same cross-organization parent 403 ATTACHMENT_PARENT_ACCESS because its verdict is an EDIT-access verdict that folds existence in; the verb has no caller to ask, so its verdict is existence under the wall. Two verdict kinds, two envelopes, both documented on the contract docblock.
  • 403 FILES_DISABLED: not the verb's own — plugin-audit's enforceFilesCapability on the sys_attachment beforeInsert, which carries NO isSystem early return (audit-writers.ts lines 2241-2260), so it judges the system insert exactly as a user's. RIGHT: the gate's decision, never a copy of it.
  • Any other hook refusal propagates unchanged; the one store-wrapped case (engine refusal of the sys_file write surfacing as StorageMetadataStoreError) is declared in the PR's acceptance notes, see ③.

The upload doors' size refusal: a pure move. RIGHT. On main, storage-routes.ts held UPLOAD_TOO_LARGE_STATUS = 413, UPLOAD_TOO_LARGE_CODE: RegisteredErrorCode = 'PAYLOAD_TOO_LARGE', uploadTooLargeMessage and the predicate maxUploadBytes === undefined || !Number.isFinite(bytes) || bytes at-or-under maxUploadBytes inside requireWithinUploadLimit. At the head the two constants, the message function (same rounding, same sentence) and the predicate live in storage-limits.ts as uploadSizeRefusal; requireWithinUploadLimit now sends sendError(res, UPLOAD_TOO_LARGE_STATUS, UPLOAD_TOO_LARGE_CODE, refusal). All six door call sites are unchanged in position and in their measured lambdas (head 898 / 1030 / 1173 / 1187 / 1536 / 1546 against main 925 / 1057 / 1200 / 1214 / 1563 / 1573). Same status, same code, same words at every door; the claim's ⛔ on the catch-all 5xx arms and on metadata-store.ts is respected (neither touched).

"No refusal leaves a committed sys_file" — traced on every path the code takes. HOLDS.

  • Before step ③ (validation, 503, 413, parent 404): nothing written.
  • Step ③ createFile writes the row pending; a throw there leaves nothing.
  • Step ④ upload failure: discard deletes the bytes then the pending row; on a byte-delete failure the row is kept pending on purpose, and sys_file declares retention: { maxAge: '7d', onlyWhen: { status: 'pending' } } with the reap guard deleting a pending row's bytes (system-file.object.ts line 202, attachment-lifecycle.ts), so the residue is reclaimed and never committed.
  • Step ⑤: the sys_attachment insert and the status: 'committed' update run inside engine.transaction(cb, base). The store's updateFile builds its own { isSystem, tenantId } context without the handle, and that is still inside the unit: ObjectQL's transaction runs the callback under runOpenedUnit's AsyncLocalStorage store, so operations issued during the callback bind to the transaction's connection ambiently (engine.ts 19992-20044, contract docblock "operations issued during the callback ALSO bind to it ambiently"). An insert refused by a gate never reaches the commit. A commit refused after the insert rolls back under an owned transaction; under a joined one or on an engine with no beginTransaction (both reported owned: false — verified in engine.ts, the degrade is "honest", { owned: false }), the verb deletes the attachment row it inserted (EngineDeleteOptions extends BaseEngineOptions, so { where, context } in one object is the contract's shape). Either way discard then removes bytes and pending row.
  • A rollback of an OUTER transaction the verb joined (after the verb returned) unwinds the commit update and the attachment row together, leaving a pending row for retention.
  • Crash mid-call: pending row plus bytes at most, reclaimed by retention. Declared by the dev as the one UNPINNED half (ablation A2b, committed-first stayed green); the card's invariant itself is pinned by A2 (4 red) and A4/A5 (3 red).

Security read — does the verb, acting as the system, open any write the upload door would refuse for the same parent and organization? NO, within everything that is a property of the parent, the organization and the bytes. The door's sys_attachment insert is judged by three kinds of gate: (a) size, enable.files, and the parent's existence under the tenant wall — the verb runs all three, two through the identical code and the third (the wall) through the package's one wall-column resolver (createWallOrganizationResolver, the computeTenantField mirror, never a hard-coded column) over a system read by id; (b) the caller-identity gates — EDIT on the parent through mayEditParent / sharing checkEdit, the sys_attachment create grant, uploaded_by stamping, the refused-attach tombstone of the caller's own upload (#22466) — all of which return at if (ctx?.session?.isSystem) return; (attachment-access-hooks.ts line 519) or are the security layer's user checks; a system producer has no identity for them to judge, which is the context the card prescribes, and uploaded_by is required: false on sys_attachment so the null stamp is a legal row whose uploader shortcut nobody can later claim; (c) the organization stamp: the verb writes sys_file through StorageMetadataStore.createFile with a StorageWriteContext (the tenantId → injectTenantOnInsert channel the doors use) and sys_attachment with { isSystem, tenantId }, in the parent's own organization or, for a global parent, the caller's — the same organization the door's session would stamp. Named, by design and documented: organizationId: null lets a system caller reach a parent in ANY organization and writes both rows into that organization. That is not a bypass of a door gate (no door exists for a session with no organization on a walled deployment) but it is a reach no door has; the consumer (#23307) must pass the mailbox's organization, never null, to stay walled. One more named point: the parent-wall refusal is the one check the verb OWNS rather than shares (the door's equivalent is the engine wall under a user session, which a system read cannot use); it is built on the shared resolver, pinned with its negative control (A3), and is the minimal addition the card's "parent outside the organization" acceptance requires — not a second copy of a door check.

Composition parity. When plugin-audit is not composed, neither the door nor the verb has an enable.files gate — same engine, same hooks, same answer; the verb is armed only after every plugin's kernel:ready handler, so no earlier write slips past a gate that is installed later.

② Semver level

RIGHT. .changeset/23306-storage-attach-verb.md declares '@objectstack/spec': minor and '@objectstack/service-storage': minor, which is what the diff publishes: four additive exports on @objectstack/spec/contracts (0 removed, 0 renamed, no retired key, no tombstone owed) and one new registered service on @objectstack/service-storage (the route module's size refusal is a move with no change in any door's answer, and no new export reaches index.ts). Clause-②: yes (widening: …) on the changeset body and Clause-②: yes on the PR body match the claim's Clause-②: yes; readClause2Line reads the arm as widening (the exact regex admits (widening followed by :, excluding only a word character or a pipe), so the declaration is declared / yes / widening, non-breaking, and yes takes at least minor — satisfied. No ADR-0087 disposition is owed (non-breaking). Check Changeset on the head: success. Pre-mode: spec at 18.0.0-next.0, minor admitted (check-changeset-no-major exit 0 in the dev's gate list, and Lint & Repo Gates success on the head).

③ Boundary flags

Dev report 6160278988: open_questions: [] — nothing to answer or escalate there. Each deviations entry and each out_of_scope_findings entry, judged:

  1. Files outside the claim's declared surface — content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md: forced by check:tenant-audit-census (the verb's two sys_attachment writes, both carrying a context, move the census 226 → 228; the diff shows exactly those two new ledger rows plus the mechanically re-derived figures and stamp). packages/services/service-storage/package.json, vitest.config.ts, pnpm-lock.yaml: a test-only @objectstack/plugin-audit devDependency so the FILES_DISABLED pin runs the real gate rather than a stand-in; the alias follows the package's existing core / driver-sql source-alias precedent, the lockfile change is the three-line importer entry only, check:workspace-manifest-cycles and Validate Package Dependencies are green. The claim said "stop on a breach and explain it in the report"; the dev explained each. ANSWERED: inside the card's scope, each forced and minimal.
  2. Lockfile kept to the importer entry (an unrelated esbuild re-resolution dropped). ANSWERED, correct hygiene.
  3. A temporary probe test committed then deleted on the branch; absent from the net diff (15 files, none named zz-probe). ANSWERED.
  4. The storage-routes.ts extraction is a move; main (with PR fix(types): one caught-fault writer, sendServerFault; four route families' catch-all 5xx withhold the caught error's text #23346) merged before the PR opened; census conflict resolved by re-deriving. ANSWERED, verified in ① against origin/main.
  5. "The card's parent-access hooks do not run under the system context the card prescribes." ANSWERED, not escalated: the hooks ARE in the verb's path (installed on the engine the verb writes through, and the verb is armed only after they are), and their system-context early return is their own pre-existing, declared design — the card itself prescribes the system context, and its Acceptance names three refusals (quota, files disabled, parent outside the organization), all of which run and are pinned with negative controls. What a system producer cannot be asked (a user's EDIT right) the card never listed as an acceptance refusal. The consequence belongs to the consumer: the producer, not the verb, decides which parent a file is attached to, with no per-user authorization — appropriate for inbound mail, and service-mailbox: store an ingested message's attachments, and each forwarded message part as an .eml attachment, through the server-side writer: the attachment item of #22871, chained #23307's design should say so.
  6. Harness attribution reminder versus AGENTS.md's model-free trailer pair: AGENTS.md followed. ANSWERED, correct in-repo.
  7. The full @objectstack/spec local suite not measured locally (narrowed to src/contracts, declared). ANSWERED by the head's check-runs, recorded below as found.

Out-of-scope findings (noted, not filed): (a) a system call with organizationId: null against a global parent on a walled deployment surfaces the engine's SystemWriteOrganizationRequiredError as a StorageMetadataStoreError rather than a 4xx — declared in the PR's acceptance notes, the doors' behaviour too by source reading, unmeasured; a composition error of the caller, acceptable as a note under Prime Directive #10. (b) On an engine with NO transaction, a process death between the attachment insert and the commit leaves an attachment row pointing at a pending row the reap guard reclaims after 7d — inferred, not reproduced; a note is the right carrier. (c) No docs page names the service; the contract docblock is its documentation and #23307's dev is the carrier. Accepted.

Scope against the card and triage 6151767880: one verb in @objectstack/service-storage, declared as a service contract in packages/spec/src/contracts/storage-service.ts with its generated projections, one changeset, service-mailbox untouched, the catch-all 5xx arms and metadata-store.ts untouched. Direction "one writer, no second copy" held: size verdict shared by move, sys_file writer shared, sys_attachment judged by the engine's own gates, organization stamp through the shared channel; the one verb-owned check is named in ①. No governed surface in the file list (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, .github/workflows/** all absent); 1,119 changed lines, under the 3,000 ceiling; head repo equals base repo.

Check-runs on the head as found (read once, about 03:39Z): Lint & Repo Gates success · Build Core success · Governed Surface Queue Guard success · Check Changeset success · Check PR Size success · Validate Package Dependencies success · Lint · ESLint, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · docs & code guards, Lint · PM dispatch-gates, the three Lint · scoped lanes, Spec property liveness, Type Check · debt ledger, Type Check · source gates, Build Docs, Dogfood Regression Gate (summary), Auto Label, Flag docs affected by code changes, Check Documentation Links, the three claim/path guards and Part-of PR must not also close its card all success · Test Core (1/6) through (6/6) in_progress, no Test Core summary yet · Type Check · consumer gates and Type Check · workspace in_progress, no TypeScript Type Check summary yet · Dogfood Verify CLI, the sharded Dogfood Regression Gate, Temporal Conformance (live PG + MySQL), Console Pin Gate, Packed-tarball smoke skipped (full-battery or opt-in, not PR-lane). The dev's own reading at the final head: 956 service-storage tests and 453 spec src/contracts tests green, both typechecks exit 0, check:generated 14 of 14 current. The landing seat reads the two pending summaries green before arming; in_progress is not a pass.

Implemented-by: claude/issue-23306-storage-attach-verb
Reviewed-by: session_01YRp5z4kK6vgYkrdH97ztsy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Regen-provenance: 6160481844 · ac4fcfdaf67ece3b38fa2102d9aaf7f4eba1b498 → 362929d9e524332a7d52b7f7d193f59e0a45685b · unexplainedPathsBetween (scripts/pm/record-recognisers.mjs) against origin/main → (empty)

domain:spec seat 1 · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · 2026-10-14T04:03Z.


Generated by Claude Code

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

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants