Skip to content

Commit 1edceb1

Browse files
docs(qa): wave-3 accuracy slice S7 — integration-system, attachments-storage, cli (#28373)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01LjZwNRFiG2fkp8SNf6rHNx?thread=cmsg_01LjZwNRFiG2fkp8SNf6rHNxPFBmNgjEvS25JDSkgtHHvX)_ Fixes #28341 Part of #27377 (wave-3 checklist accuracy, slice S7) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:校准三份 QA 测试清单 JSON(cli、attachments-storage、integration-system)中 14 个条目的文字,覆盖切片 S7 的全部 28 行普查记录;每条 revision +1,main 的 history 原样保留为前缀,追加一行引用对应的 QA 运行记录与 #27377。另给 cli.json 的 `cli-18-prerelease-db` 配方加了第 3 步:恢复后把重置令牌的过期时间往后挪。 - 为什么改:wave-3 的运行记录(#27392、#27409、#27438、#27441、#27481)实测发现这些条目的步骤、夹具或校验说法与当前代码不符(例如 `os serve` 只打印拒绝消息、从不打印错误码;邮件发送接口 200 的响应体没有 `result` 包装;`?schema=` 在 SQLite 上现在答 400 而非整表)。 - 风险与回滚:只改 `docs/qa/` 下三份清单,不动产品代码、不发布;没有任何条款被放宽,有三处变得更严格。回滚即 revert 本 PR。 - 先看:`cli.json` 的 `cli.organization-ownership-ceremony`(7 行合并成一次修订,acceptance[1] 和 [3] 改动最大);`integration-system.json` 的 `external-schema-introspection` acceptance[1].verify(由“200 整表”收紧为“400 拒绝”);`attachments-storage.json` 的 `upload-limits-scope-and-ownership` steps[4]。 - 两分钟验证:`pnpm check:platform-checklist`,预期 `OK — 16 areas, 352 items`,symbol anchors `1079/1089`(基线 1072/1082,新增 7 个锚点全部可解析)。 - 需要你做的:无,席位落地。 ## What changes Three files, 14 items, and one area recipe step. Each item's `revision` is +1 from main. Main's `history` is kept as an exact prefix, and one row is appended citing the run record the finding came from (`ref`) and the wave anchor #27377 (in `change`). All 28 census rows were re-checked against the source at base `22ce3d13fe` before editing. All 28 still held; none had been fixed upstream. No commit touched the three files between the census base `501c864dd5` and `22ce3d13fe` (`git log 501c864..origin/main` over the three paths is empty; `501c864dd5` is an ancestor of `origin/main`, exit 0, and the range spans 20 commits). No clause is weakened. | item | census rows | change (brief) | record | |---|---|---|---| | cli.fresh-install-migrate-apply-first | 19 | requires[0]: `os migrate plan` creates no file on an absent path (plan.ts `readOnlyProbe`), so only `os dev` / `os serve` are named as table-creating; the `test ! -e` check stays | #27392 | | cli.hook-body-extraction-gates | 20 | steps[2], requires[0], acceptance[3].verify: adds the data-body probe (the retired comment inside `body.source`), which expects `body.capabilities` `[]`. The TS-config probe passes trivially because esbuild strips the comment | #27392 | | cli.qa-suite-execution | 21 | acceptance[4]: the refused suite adds one to the scenario tally (`FAILED: 3 scenarios failed.` for the steps[5] trio, `summaryLine`) | #27392 | | cli.organization-ownership-ceremony | 39, 62, 63, 64, 132, 143, 144 | requires[1]: the v17 recipe (create-objectstack@17.7.0 blank scaffold, `--legacy-peer-deps`, one own `os dev` boot). acceptance[1]: the oracle is the primary file's checksum; under `--dev` the telemetry sibling gets a row-level read. acceptance[3]: the code literal is deferred to the pins (serve.ts prints the message only), as in platform-core.v18-boot-refusals acceptance[1], and the named command carries `--dev` on a dev serve. steps[5]: the reset token is re-dated (new recipe step 3); under a production serve the delivery row lands in the primary (`resolveTelemetryDbPath`) | #27409, #27481 | | attachments-storage.download-authz-both-sides | 54 | steps[6]: the panel's place depends on the width tier (`recordPageTierFor`). At 1600px it is resident; at 1280px it sits behind the `Related` rail toggle. There is no Attachments tab | #27409 | | attachments-storage.orphan-tombstone-reap | 55 | steps[1]: the sweep has no HTTP door. The trigger is a restart and the first sweep 60 s later (`DEFAULT_LIFECYCLE_INITIAL_DELAY_MS`) | #27409 | | attachments-storage.upload-session-abort | 56 | steps[0] to [2]: `resumeToken` and the `x-resume-token` header (else 403 `INVALID_RESUME_TOKEN`); complete takes `parts: [{chunkIndex, eTag}]` | #27409 | | attachments-storage.upload-limits-scope-and-ownership | 57 | steps[4], acceptance[5]: a data-API write is copy-on-claim (`copyOwnedFile`), so the id is planted with a driver-level write and read back raw; the reader is one refused `sys_file` read | #27409 | | integration-system.job-scheduled-run | 118 | acceptance[5]: the `job.id` prescription now ends with the aged-out `os migrate meta` sentence, not `--from 16` | #27441 | | integration-system.notify-inbox-delivery | 119 | steps[0]: `assignee` is a text field holding the persona's email | #27441 | | integration-system.external-schema-drift-gate | 151 (gap), 192 | acceptance[0], steps[2]: the boot prints renderDiffMessage's text after `Bootstrap failed after plugins started`, exits 1, and never prints the class name. steps[7] (new, appended): the negative[5] federation-free boot recipe (stock app-todo) | #27438 | | integration-system.external-schema-introspection | 188 | acceptance[1].verify, knownGaps[0]: on SQLite, `?schema=X` answers 400 `EXTERNAL_DATASOURCE_ERROR` (the #24664 refusal); empty or repeated `?schema` still answer 200 with the full set | #27438 | | integration-system.email-template-render | 189, 190, 191 | acceptance[7]: the 200 body is a top-level `{id, status, messageId}`. steps[5]: `fromOverride` is `{name?, address}`. acceptance[1] and [2]: the evidence is the `sys_notification_delivery` row (`dead`, `MISSING_VARIABLES: title` / `TEMPLATE_INACTIVE: NAME`), and the oracle moves from log to api | #27438 | | integration-system.notification-org-and-redeliver-eligibility | 193 | requires[2]: the `sys_webhook` create needs `method: 'post'` (lower-case select) and `definition_json` | #27438 | **Area recipe.** `cli.json` fixtures `cli-18-prerelease-db` gains `sequence[2]` (step 3). It is one `node:sqlite` UPDATE that moves the dump's one `sys_verification` row a day ahead and prints `1`. The call was replayed against the committed `database.sql` loaded into a scratch file: it printed `1`, and `expires_at` read back moved forward. **Where clauses got stronger.** external-schema-introspection acceptance[1].verify now requires the 400 refusal, where 200 with the full set used to pass. upload-limits acceptance[5] adds a raw column read, so a minted copy cannot pass for the planted id. hook-body acceptance[3] adds the data-body probe, which the old probe could not fail. ## Tests None. The diff is ledger text in `docs/qa/`, so no package test reads it. The ledger's own gate is the evidence (Verification). ## Acceptance notes - Census rows 62 and 144 (and the cli half of an anchor note about the optional `--dev`) are one edit to acceptance[3]: the code is deferred to the pins, and the named command matches the boot (`ceremonyCommand` in organization-ownership-boot-guard.ts). - Census rows 63 and 132 (the reset-token fixture) are fixed in the recipe, not by scoring the leg differently. The ceremony's steps[5] points at the new recipe step. - The recipe's `requires[2]` still says a production `os serve` over the restored pair is NOT MEASURED. QA run #27409 drove one, but that requirement is not one of this slice's rows, so it is left as it was. - upload-limits: a reader holding `sys_file` read sees a foreign file's metadata in full. This is reported as STALE, not as a defect. The hydration code mints the refused marker only on the refused-read arm, and the download door judges bytes by the owning record on its own (engine-file-reference-hydration.ts header). - No product defect found. No out-of-scope file touched. ## Verification <details><summary>Gates at head 2b4bc3b (local, derived by dispatch-gates --commands)</summary> Head `2b4bc3be`, base `22ce3d13fe`. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 16 families from the three changed paths (188 changed lines, under the 3000 threshold). Every one exited 0: ``` node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 (19451 files, 0 disagree) node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:platform-checklist :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` `pnpm check:platform-checklist`: `OK — 16 areas, 352 items (347 active, 1 planned)`, symbol anchors `1079/1089` resolved (base 1072/1082: the 7 new anchors all resolve; the 10 unresolved are the unchanged #16898 residual). `--ran` reconciliation: `16 derived, 16 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero: all 16 record an exit code). On the first pass, five families exited 3 (PREREQUISITE NOT MET: a fresh worktree with no `node_modules`, and `@objectstack/formula` / `@objectstack/lint` unbuilt). They were re-run after `pnpm install --frozen-lockfile` and `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` (lock VERDICT command-exit 0), and the lines above are those re-runs. CI's own families are on this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QUs3BwBJH8nkmvFpQugNUm)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent edf9714 commit 1edceb1

3 files changed

Lines changed: 137 additions & 51 deletions

File tree

‎docs/qa/platform-checklist/areas/attachments-storage.json‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@
254254
"title": "Gated downloads: 401 anonymous, 403 parent-invisible, signed URL for the entitled — per gating class (attachments-scope / field-owned / public_read opt-out / ungated)",
255255
"since": "v15.1",
256256
"status": "active",
257-
"revision": 9,
257+
"revision": 10,
258258
"priority": "P1",
259259
"surface": "mixed",
260260
"personas": ["admin (uploader/owner)", "signed-up member who cannot read the parent record (qa-scratch-authz persona B: the API deny clauses)",
@@ -281,7 +281,7 @@
281281
"GET the same routes as the uploader/admin and follow the signed URL",
282282
"compare TTLs: the gated grant uses the short downloadTtl (default 300s), not the 3600s presignedTtl — read expiry material from the minted URL/descriptor where the adapter exposes it",
283283
"flip one file to acl='public_read' (system write) and GET /files/<fileId> anonymously — the opt-out must restore the stable anonymous capability URL",
284-
"in the browser, as persona A (reads qa_shared through its qa_att_reader binding, qa-scratch-authz step 6), open a qa_shared record that carries an attachments-scope file (/_console/apps/showcase_app/qa_shared/record/ID), open its Attachments tab and confirm the file is listed; then, server-side and WITHOUT reloading the page, remove A's read (as admin DELETE /api/v1/data/sys_user_permission_set/BINDING_ID, A's qa_att_reader row), click the listed file's Download, and capture the 403 on GET /api/v1/storage/files/FILE_ID/url plus the panel's surfaced copy; re-bind A afterwards (POST the same row back). Persona B cannot stand in for A here: with no read on the parent the record page itself refuses first ('You don't have access to qa_vault records'), so RecordAttachmentsPanel never mounts and there is no download to attempt. A reload after the revoke re-runs the record and listing gates and refuses before any download, which is not the download denial acceptance[4] scores"
284+
"in the browser, as persona A (reads qa_shared through its qa_att_reader binding, qa-scratch-authz step 6), open a qa_shared record that carries an attachments-scope file (/_console/apps/showcase_app/qa_shared/record/ID), find its attachments panel and confirm the file is listed. Where the panel sits depends on the record page's width tier, measured on the page's container, not the viewport (ui/packages/plugin-detail/src/synth/recordPageColumns.ts#recordPageTierFor): at a 1600px window with the sidebar expanded the page is three-column and the panel closes the resident related column; at 1280px it is two-column, the related column is a rail behind the 'Related' toggle above the columns (data-testid record-related-rail-toggle), closed by default with its panels unmounted, so open the rail first; narrower than the two-column floor, the panel closes the Related tab. There is no separate Attachments tab. Use 1600px, or open the rail, and record which; then, server-side and WITHOUT reloading the page, remove A's read (as admin DELETE /api/v1/data/sys_user_permission_set/BINDING_ID, A's qa_att_reader row), click the listed file's Download, and capture the 403 on GET /api/v1/storage/files/FILE_ID/url plus the panel's surfaced copy; re-bind A afterwards (POST the same row back). Persona B cannot stand in for A here: with no read on the parent the record page itself refuses first ('You don't have access to qa_vault records'), so RecordAttachmentsPanel never mounts and there is no download to attempt. A reload after the revoke re-runs the record and listing gates and refuses before any download, which is not the download denial acceptance[4] scores"
285285
],
286286
"acceptance": [
287287
{
@@ -371,6 +371,12 @@
371371
"date": "2026-10-28",
372372
"change": "automated.ref names the component pin of clause 4. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26468, Tier 1 half A, at 756220c4; wave anchor #25807, slice B): RecordAttachmentsPanel.test.tsx asserts the friendly denial copy for both envelope dialects at component layer, so the ref's 'It has NO pin' was stale; it now says the pin lifts clause 4 and negative[1] to partial and the browser leg stays manual. The run's other note (clause 2's admin owner is also the parent owner, so the uploader bypass is never isolated) is already in the ref. Re-read at base fd246c22 (the test file exists). No clause changed",
373373
"ref": "#25807"
374+
},
375+
{
376+
"revision": 10,
377+
"date": "2026-10-31",
378+
"change": "steps[6] said to open the record's 'Attachments tab'. QA run #27409 saw no attachments panel at 1280px and found it at 1600px. The synthesized record page lays out by its container's width tier (recordPageTierFor: three-column from 1440px of window with the sidebar expanded; two-column below that, where the related column, attachments last, is a rail behind the 'Related' toggle and unmounted until opened; tabs below the two-column floor, where attachments close the Related tab). The step now names the viewport and the toggle. No clause changed. Read from source at base 22ce3d13fe (ui/packages/plugin-detail). Wave anchor #27377, wave-3 accuracy slice S7 (#28341)",
379+
"ref": "#27409"
374380
}
375381
]
376382
},
@@ -726,7 +732,7 @@
726732
"title": "The platform lifecycle sweep reaps expired tombstones and abandoned pending uploads WITH byte reclaim; sweep-time re-verification vetoes rather than losing data",
727733
"since": "v15.1",
728734
"status": "active",
729-
"revision": 3,
735+
"revision": 4,
730736
"priority": "P2",
731737
"surface": "api",
732738
"personas": ["admin (setup access, drives the sweep)"],
@@ -738,7 +744,7 @@
738744
},
739745
"steps": [
740746
"run the pinned dogfood lifecycle suite first (RUNNER rule 6 — do not hand re-prove what it pins) and capture its output",
741-
"for the hand-driven spot check: tombstone a file (last join-row delete), backdate deleted_at past 30d, trigger the LifecycleService sweep, then verify the sys_file row AND its storage bytes are gone",
747+
"for the hand-driven spot check (the sweep has no HTTP door: the trigger here and in every later step is a restart after the backdate, then a wait past the first sweep, which runs 60 s after boot (packages/objectql/src/lifecycle/lifecycle-service.ts#DEFAULT_LIFECYCLE_INITIAL_DELAY_MS) and hourly after that (DEFAULT_LIFECYCLE_SWEEP_MS); a boot with lifecycle disabled (OS_LIFECYCLE_DISABLED=1) never sweeps): tombstone a file (last join-row delete), backdate deleted_at past 30d, trigger the LifecycleService sweep, then verify the sys_file row AND its storage bytes are gone",
742748
"create a pending upload, backdate past the 7d retention, sweep, verify row + best-effort bytes gone",
743749
"create a tombstone, then re-add a sys_attachment reference BEHIND the hooks (system write), backdate, sweep — verify the row is un-tombstoned (committed, deleted_at null), not reaped",
744750
"verify fresh tombstones (inside the window), committed rows and NULL-deleted_at rows all survive the sweep untouched"
@@ -800,6 +806,12 @@
800806
"date": "2026-10-26",
801807
"change": "One checklist-accuracy finding of Tier 1 record #25815 (release-18.2.0-tier1, at 6679644a) (acceptance[1]), re-read on main f6c61932: the dogfood sweep is lifecycle.sweep() called in-process after HTTP-driven setup (no door exists for it), and the failed-byte-delete-leaves-the-row leg and its warn log are pinned only in attachment-lifecycle.test.ts with a fake engine. acceptance[1].verify now names those layers and scores the failed-delete leg partial unless a run stages it. No clause or oracle changed.",
802808
"ref": "#25839"
809+
},
810+
{
811+
"revision": 4,
812+
"date": "2026-10-31",
813+
"change": "steps[1] said 'trigger the LifecycleService sweep' without naming a trigger. QA run #27409 found the sweep has no HTTP door, and a restart runs the scheduler's first sweep 60 s after boot. lifecycle-service.ts arms a setTimeout of DEFAULT_LIFECYCLE_INITIAL_DELAY_MS (60_000), then a setInterval of DEFAULT_LIFECYCLE_SWEEP_MS (hourly). No route calls sweep(). The step now names that trigger for every sweep in the item. No clause changed. Read from source at base 22ce3d13fe. Wave anchor #27377, wave-3 accuracy slice S7 (#28341)",
814+
"ref": "#27409"
803815
}
804816
]
805817
},
@@ -808,7 +820,7 @@
808820
"title": "Chunked uploads are resumable; abandoned sessions are reaped and their backend multipart uploads ABORTED before the row (the only pointer) is deleted",
809821
"since": "v15.1",
810822
"status": "active",
811-
"revision": 7,
823+
"revision": 8,
812824
"priority": "P2",
813825
"surface": "api",
814826
"personas": ["seeded admin (admin@objectos.ai)"],
@@ -823,9 +835,9 @@
823835
]
824836
},
825837
"steps": [
826-
"POST /api/v1/storage/upload/chunked { filename, mimeType, totalSize, chunkSize } and capture { uploadId, fileId } — the init route's required trio is filename/mimeType/`totalSize` (a body spelling `size` is refused 400 INVALID_REQUEST), and the body is parsed with InitiateChunkedUploadRequestSchema: a `totalSize` that is not a whole JSON number of at least 1, or a `chunkSize` below 5 MiB (5242880), is refused 400 INVALID_REQUEST naming the field, never clamped; an omitted `chunkSize` takes the declared 5242880 default (storage-routes.ts)",
827-
"PUT one chunk, then GET /upload/chunked/<uploadId>/progress and record uploadedChunks/percentComplete/status ('in_progress') — progress is the first step of the SDK's resumeUpload",
828-
"complete a full session on a second upload and verify its sys_upload_session row reaches status 'completed'",
838+
"POST /api/v1/storage/upload/chunked { filename, mimeType, totalSize, chunkSize } and capture { uploadId, resumeToken, fileId, totalChunks } (every later chunk PUT carries that resumeToken) — the init route's required trio is filename/mimeType/`totalSize` (a body spelling `size` is refused 400 INVALID_REQUEST), and the body is parsed with InitiateChunkedUploadRequestSchema: a `totalSize` that is not a whole JSON number of at least 1, or a `chunkSize` below 5 MiB (5242880), is refused 400 INVALID_REQUEST naming the field, never clamped; an omitted `chunkSize` takes the declared 5242880 default (storage-routes.ts)",
839+
"PUT one chunk — PUT /api/v1/storage/upload/chunked/<uploadId>/chunk/0 with the bytes as the body and the init answer's resumeToken in the `x-resume-token` header (a missing or wrong token answers 403 INVALID_RESUME_TOKEN, storage-routes.ts) — and keep the eTag it answers; then GET /upload/chunked/<uploadId>/progress and record uploadedChunks/percentComplete/status ('in_progress') — progress is the first step of the SDK's resumeUpload",
840+
"complete a full session on a second upload — every chunk PUT as in steps[1], then POST /api/v1/storage/upload/chunked/<uploadId>/complete with { parts: [{ chunkIndex, eTag }, …] }, chunkIndex zero-based and eTag the string each chunk PUT answered (a malformed entry answers 400 INVALID_REQUEST, an eTag the session does not hold 409 RESOURCE_CONFLICT) — and verify its sys_upload_session row reaches status 'completed'",
829841
"abandon the first session mid-flight; backdate its expires_at past the 1d TTL (system write) and trigger the lifecycle sweep",
830842
"verify the abandoned session's row is reaped AND the storage adapter's abortChunkedUpload was invoked for its backend_upload_id (dogfood suite instruments this)",
831843
"verify the completed session's row is reaped by the 7d terminal-status retention WITHOUT an abort attempt (an abort on a finalized multipart would NoSuchUpload-error and wedge the reap)",
@@ -909,6 +921,12 @@
909921
"date": "2026-10-29",
910922
"change": "steps[0] and the storage-routes.ts source line described the 5 MiB chunkSize floor (a smaller requested chunkSize silently clamped up). The init door now parses its body with InitiateChunkedUploadRequestSchema, so a chunkSize below 5242880 and a totalSize that is not a whole number of at least 1 are refused 400 INVALID_REQUEST naming the field; an omitted chunkSize keeps the 5242880 default. Both strings now describe the refusal. No clause, oracle or variant changed",
911923
"ref": "#27549"
924+
},
925+
{
926+
"revision": 8,
927+
"date": "2026-10-31",
928+
"change": "steps[0] to [2] name the chunk door's resume token and the complete body. QA run #27409 noted that 'PUT one chunk' needs the x-resume-token header and that complete takes parts [{chunkIndex, eTag}]. storage-routes.ts: the init answer carries resumeToken, the chunk PUT refuses a missing or wrong x-resume-token with 403 INVALID_RESUME_TOKEN, and complete parses req.body.parts as {chunkIndex, eTag} entries (listedParts) and judges them against the held chunks. No clause changed. Read from source at base 22ce3d13fe. Wave anchor #27377, wave-3 accuracy slice S7 (#28341)",
929+
"ref": "#27409"
912930
}
913931
]
914932
},
@@ -1125,7 +1143,7 @@
11251143
"title": "Upload contract on v18: a declared size over the limit (100 MiB default, moved live by the max_upload_mb setting) is 413 with nothing written, an unknown scope is 400 naming the allowed values (public is retired), only the uploader may complete or poll an upload, an unclaimed private file is 401 to anonymous, and a file id copied from another record hydrates as {id, metadataRefused:true}",
11261144
"since": "v18",
11271145
"status": "active",
1128-
"revision": 3,
1146+
"revision": 4,
11291147
"priority": "P1",
11301148
"surface": "api",
11311149
"personas": [
@@ -1144,7 +1162,7 @@
11441162
"POST presigned with scope \"avatars\", \"PUBLIC\", \"public\", null; and with scope omitted",
11451163
"as the admin start an upload; as the member POST /api/v1/storage/upload/complete and GET the chunked progress for it",
11461164
"anonymously GET the download URL of an unclaimed private file",
1147-
"as the member GET a record whose file field holds an id copied from another record"
1165+
"plant a file id another record owns into a second record's file field with a DRIVER-LEVEL write (a direct SQL UPDATE of that record's column, the record's own file kept in another file field or on a sibling record for the contrast). A data-API write cannot plant it: the field-reference hooks copy a file another record owns on claim, so the written record gets its own copy and never holds the foreign id (packages/services/service-storage/src/file-reference-lifecycle.ts, copyOwnedFile). Read the column back raw to confirm it holds the foreign id. Then GET that record as a reader REFUSED sys_file read but able to read the record (e.g. a qa-scratch-authz persona with qa_shared read and no sys_file grant, as QA run #27409 drove it). A reader holding sys_file read gets the foreign file's full metadata, because the refused marker is minted only when the caller's sys_file read is refused (packages/objectql/src/engine-file-reference-hydration.ts#serveRefusedFileHydration), so such a reader cannot score this clause"
11481166
],
11491167
"acceptance": [
11501168
{
@@ -1178,10 +1196,10 @@
11781196
"evidence": "the answer"
11791197
},
11801198
{
1181-
"clause": "a copied file id hydrates as {id, metadataRefused:true}; the record's own file hydrates fully",
1199+
"clause": "for a reader refused sys_file read, a file id the record does not own (planted by the steps[4] driver-level write) hydrates as {id, metadataRefused:true}; the record's own file hydrates fully ({id, name, size, mimeType, url})",
11821200
"oracle": "api",
1183-
"verify": "field value shapes",
1184-
"evidence": "both excerpts"
1201+
"verify": "field value shapes as the steps[4] reader, plus the raw column read showing the foreign id was really planted (a copy minted by a data-API write would hydrate fully as the record's own file and prove nothing)",
1202+
"evidence": "both excerpts and the raw column read"
11851203
}
11861204
],
11871205
"negative": [
@@ -1220,6 +1238,12 @@
12201238
"date": "2026-10-28",
12211239
"change": "automated.ref names two HTTP pins and rescores acceptance[3]. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26468, Tier 1 half A, at 756220c4; wave anchor #25807, slice B): storage-upload-limits.dogfood.test.ts (413 and the restartless save, over HTTP) and storage-upload-door-ownership.wall.test.ts (403 PERMISSION_DENIED on commit, chunked completion and progress, booted wall, over HTTP) were missing from the ref; the second makes acceptance[3] pass at the HTTP layer. Re-read at base fd246c22 (both files exist; the wall file drives stack.api and asserts error.code PERMISSION_DENIED). No clause changed",
12221240
"ref": "#25807"
1241+
},
1242+
{
1243+
"revision": 4,
1244+
"date": "2026-10-31",
1245+
"change": "acceptance[5] and steps[4] say how the copied-id state is reached and for whom. QA run #27409 noted that a data-API write of a copied file id is copy-on-write, so only a driver-level write reaches the state, and that a reader holding sys_file read sees the foreign file in full. file-reference-lifecycle.ts copies a file another record owns on claim (copyOwnedFile). engine-file-reference-hydration.ts mints {id, metadataRefused:true} only on the refused-sys_file-read arm, judged per record. The step plants the id below the hooks and reads the column back. The reader is one refused sys_file read, as the run drove it (reader D: qa_shared read, no sys_file). Stronger, not weaker: the raw read rules out a minted copy passing as the planted id. Read from source at base 22ce3d13fe. Wave anchor #27377, wave-3 accuracy slice S7 (#28341)",
1246+
"ref": "#27409"
12231247
}
12241248
]
12251249
},

0 commit comments

Comments
 (0)