Repository navigation
feat(service-storage): the file-attachments service, a server-side verb that stores bytes and attaches them to a record - #23386
Conversation
…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>
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>
📓 Docs Drift CheckThis PR changes 2 package(s): 39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 144 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 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
|
Contract reviewServed-tier: Inputs read: card #23306 (body; triage ① Derived judgmentsPublic-surface changes the diff implies — each named.
The verb's refusals and their envelopes — each named.
The upload doors' size refusal: a pure move. RIGHT. On "No refusal leaves a committed
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 Composition parity. When ② Semver levelRIGHT. ③ Boundary flagsDev report
Out-of-scope findings (noted, not filed): (a) a system call with Scope against the card and triage Check-runs on the head as found (read once, about 03:39Z): Implemented-by: VERDICT: PASS Generated by Claude Code |
…orage-attach-verb
|
Regen-provenance: 6160481844 ·
Generated by Claude Code |
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.
StorageServicePluginregisters the kernel servicefile-attachments(FILE_ATTACHMENT_SERVICE), declared in@objectstack/spec/contractsasIFileAttachmentService. Its one verb,attachFile({ parentObject, parentId, organizationId, filename, mimeType, data }), writes the bytes, onesys_filerow and onesys_attachmentrow 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 inStorageServicePlugin.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
ObjectQLover sqlite:memory:with the plugin's own hooks andplugin-audit's real writers installed:uploadSizeRefusalinstorage-limits.ts: the verdict the REST upload doors ask, against the sameresolveStorageLimitsover thestorageservice's Limits snapshot413 PAYLOAD_TOO_LARGE, before anything is storedsys_filerow and its organization stampStorageMetadataStore.createFile/updateFile/deleteFile, the upload doors' writer, through the sameStorageWriteContextchannelenable.filesopt-inplugin-audit'senforceFilesCapability, abeforeInserthook onsys_attachment403 FILES_DISABLED(measured, and pinned with the real gate)installAttachmentAccessHooks)sys_attachmentbeforeInserthook in this packagecheckEditwas called 0 times (measured). A server-side producer has no caller whose edit access could be asked.installAttachmentLifecycleHooksafterInsertcreateWallOrganizationResolver, the package's one answer to "which column is this record walled by"organizationIdis404 RECORD_NOT_FOUND, the same answer as a missing parentBoth rows are written in the parent's organization when it has one, otherwise in the caller's.
organizationIdis required and nullable: acting in no organization is stated, never a left-out field.Mechanism hypotheses, measured
sys_attachmentjudged bybeforeInserthooks, so the one writer is an engine insert, not a function instorage-routes.ts. Under the system context the parent-EDIT gate does not run (above). The three refusals the card names do run.git grep -ifor a storage quota concept: 0 hits; the onlyquotacode is SMS, lifecycle row counts and AI). The byte-acceptance limit ismax_upload_mb. It was enforced byrequireWithinUploadLimit, a closure insideregisterStorageFamily(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 instorage-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).mainwas 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).IStorageService.IStorageServiceis the blob store an adapter implements (LocalStorageAdapter,S3StorageAdapter, third parties), and thestorageslot'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, instorage-service.tsbesideIFileAccessDelegate.sys_file, by write order. The order is the presigned door's: thesys_filerow 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 turnscommittedonly in the sameengine.transactionas thesys_attachmentinsert. 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.tswas 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'skernel:readyhandler has installed its gates (AuditPlugininstalls theenable.filesgate from its ownkernel:ready). Before that, or with no data engine, it refuses503 SERVICE_UNAVAILABLErather 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 byFILE_ATTACHMENT_SERVICE, a settings namespace carryingmax_upload_mb, the plugin's local adapter in a temp directory, andplugin-audit's real gate.@objectstack/plugin-auditis a new devDependency, aliased to source invitest.config.tslikecoreanddriver-sql.sys_file(attachments scope, private, keyattachments/plus the file id plus the extension, bytes behind it) and onesys_attachment, both in the parent's organization.codeandstatusand that NOTHING is left (nosys_filecommitted or pending, nosys_attachment, no stored key), each beside its control (the same call without the refused condition succeeds):413 PAYLOAD_TOO_LARGE; control: exactly at the limit;enable.filesoff:403 FILES_DISABLED; control: an opted-in object;404 RECORD_NOT_FOUND; control: the same parent from its own organization. A missing parent answers the same.nulltakes the parent's; a global parent takes the caller's.503beforekernel:bootstrappedand with no engine;400 VALIDATION_ERRORfor 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.mjsin 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 HEADempty). The suite imports the mutated modules from source (relative imports, andplugin-auditthrough its source alias), so no rebuild sits between mutation and measurement. Run at00e7f3217; the five mutated files are byte-identical at the final headac4fcfdaf(git diffempty across the merge).committedup frontkernel:readyorganizationIdnot validateduploadSizeRefusaladmits everythingstorage-limits.test.ts12 red: one verdict for bothplugin-audit'senable.filesgate never refusesThe 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 mergingmain)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. Thecontractsshards ofapi-surface/andexport-origins/gained exactly the four exports.@objectstack/speclocal suite ran past the 10-minute foreground cap and was not measured locally;src/contracts(the changed directory) was.turbo ls --affectednames 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/contractswholesale, and the four names have 0 other declarations in the tree, so no downstream verdict can move through it. CI runs the farm.eslint --no-inline-config --format jsonover the 8 changed.tsfiles gives 8 files, 0 errors, 0 warnings, 0 fatal. Population:eslint.config.mjsfiles: '**/*.{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 (noparserOptions.project), so no untouched file's verdict can move.requireofdist/index.cjsandimportofdist/index.jsboth load and list the new service.node scripts/pm/dispatch-gates.mjs --commands(no paths) derives 124 families atac4fcfdaf; 120 ran with exit 0, and--ranreconciles 124 of 124 with 0 UNRUN. NOT MEASURED, 4:check:skill-examples,check:dual-build-cjs-loadsandcheck:i18nrefused 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-debtwas not run (its--re-measureis a repo-wide tsc sweep; this diff adds no package and moves no ledger entry).check:tenant-audit-censusis 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.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md:check:tenant-audit-censusre-derives the write-call-site census; the verb adds twosys_attachmentwrites, both carrying a tenant context. Regenerated withnode 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.tsandpnpm-lock.yaml:@objectstack/plugin-auditas a test-only dependency. The lockfile change is the three-line importer entry, accepted bypnpm install --frozen-lockfile; a plainpnpm installalso re-resolved an unrelated vitest peer variant, which is not in this diff.Acceptance notes
.emlattachment, through the server-side writer: the attachment item of #22871, chained #23307 reads it.organizationId: nullagainst a global parent on a walled deployment is refused by the engine's own system-insert organization rule, surfaced as the store'sStorageMetadataStoreError(whoseCause:names it), not as a4xx. That is the upload doors' behaviour for a session with no active organization too.Generated by Claude Code