Repository navigation
QA run · area:attachments-storage (9/9) · 2034975a · 2026-10-16 · 9 PASS / 0 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #23929
Copy link
Copy link
Closed
Labels
Description
QA run record for the area:attachments-storage slice of the #23878 wave (cloud runner for session session_01K3qUoyh1xFGoWzAACPMbXJ). Text only, per checklist-test §4.
Environment fingerprint
| key | value |
|---|---|
| framework sha | 2034975a47193f04db4791614f4ac9331ff211d3 (main) |
.objectui-sha pin |
daeb77fc84dd824e74c95b87cdede85afdb248e7 |
| console bundle | objectui daeb77fc built against spec a35f7a67^ (34b740f) in a separate worktree — pnpm objectui:build fails on main (#23908); pnpm check:console-sha in the boot tree: "Console dist matches the objectui pin" |
| boot tree | linked worktree at HEAD with every packages/*/dist copied from a full pnpm build of the primary checkout |
| boot | OS_PORT=41731 objectstack dev --ui --seed-admin -p 41731 -d file:(scratch)/data.db from examples/app-showcase; one restart on the same DB to mount the console |
| seed | --seed-admin (admin@objectos.ai) + stock showcase seeds |
| fixtures | qa-scratch-authz and qa-media-constraints replayed at runtime (details below) |
| browser | Playwright (Node) + /opt/pw-browsers/chromium, one context per persona, form sign-in (bearer + cookie) |
| run window | 2026-10-16 12:37Z – 13:05Z |
| execution | API items driven by hand over REST; Tier-1 pins run with vitest --maxWorkers=1 with no server up; browser clauses driven in this session (other areas fanned out to runner subagents) |
Scope
Selector area:attachments-storage → 9 runnable items (no planned, no blocked-hidden items in this area).
| item | revision |
|---|---|
| attachments-storage.presigned-upload-roundtrip | 1 |
| attachments-storage.download-authz-both-sides | 4 |
| attachments-storage.read-inherits-parent-rls | 4 |
| attachments-storage.attach-requires-parent-edit | 6 |
| attachments-storage.sys-file-status-pipeline | 2 |
| attachments-storage.orphan-tombstone-reap | 1 |
| attachments-storage.upload-session-abort | 3 |
| attachments-storage.inline-grid-receipt-cells | 3 |
| attachments-storage.field-accept-maxsize-server-enforced | 1 |
Fixture provisioning (runtime, isolated DB)
qa-scratch-authz:POST /api/v1/packages(201) forcom.objectstack.qa.attachments;PUT /api/v1/meta/object/{qa_vault,qa_shared,qa_nofiles}?package=…(200 each). Personas minted withPOST /api/v1/auth/admin/create-user+change-password(A, B, C). Step 6 (bindings) needed explicit permission sets because members hold no baseline grant on the scratch objects (every list 403 PERMISSION_DENIED before binding): authoredqa_att_reader(read on the three scratch objects; read/create/edit/delete on sys_attachment; read sys_file) for A,qa_att_baseline(sys_attachment read/create/delete, sys_file read, nothing on the parents) for B,qa_att_editor(read/create/edit on the scratch objects + sys_attachment) for C viaPUT /api/v1/meta/permission/NAME?package=…and bound withPOST /api/v1/data/sys_user_permission_set. Independent split proof before any storage clause: A readsqa_shared200 / edits 403,qa_vaultrecord 404; B reads neither parent (403 PERMISSION_DENIED) but lists sys_attachment 200; C readsqa_shared200, edit of an admin-owned row 403.qa-media-constraints:POST /api/v1/packages(201)com.objectstack.qa.media;PUT /api/v1/meta/object/qa_media?package=…(200); served metadata carriesposter.accept=["image/png","image/jpeg"],poster.maxSize=1048576,doc.accept=[".pdf"]— no parse-time strip.
Tier-1 pins run (no server up, --maxWorkers=1)
packages/qa/dogfood:attachments-permission-matrix,attachments-public-read-acl,attachments-parent-rls-count-parity,attachments-parent-rls-scan-cap,attachments-unscoped-delete-gate→ 5 files passed, 47 passed / 1 skipped (the skip is the cross-tenant(g)block:@objectstack/organizationsnot resolvable from the dogfood package — blocked(dependency) by design, ADR-0132, as the items' knownGaps record).service-storage:attachment-read-leaf.integration,attachment-read-leaf,file-reference-lifecycle,attachment-lifecycle,attachment-access-hooks→ 5 files, 203 passed.storage-routes,tombstone-download-live-reference,tombstone-hydration-download-agreement→ 3 files, 66 passed.plugin-audit:capability-gate-update-verb→ 12 passed.
Per-clause verdicts
| item | # | verdict | evidence (text) |
|---|---|---|---|
| presigned-upload-roundtrip | 0 | pass | presign with a spoofed owner_id in the body → 200 {success, data:{uploadUrl, method:PUT, headers, fileId, expiresIn:3600, downloadUrl}}; GET /data/sys_file/ID → status=pending, owner_id = the admin's session id (spoof ignored) |
| presigned-upload-roundtrip | 1 | pass | PUT bytes to /storage/_local/raw/TOKEN 200; POST /upload/complete 200; re-read status=committed |
| presigned-upload-roundtrip | 2 | pass | GET /files/ID/url → 200 {success:true, data:{url}}; GET url → 200, 2048 bytes, sha256 prefix identical up/down (867a14bd27d55ea8) |
| presigned-upload-roundtrip | 3 | pass | GET /files/ID (redirect manual) → 302 to /storage/_local/raw/…; following it → 200, same hash; the presign downloadUrl also answers 200 |
| presigned-upload-roundtrip | 4 | pass | presign with no auth → 401 {code:AUTH_REQUIRED, message:"Authentication required to upload files"}; sys_file total unchanged across the attempt |
| download-authz-both-sides | 0 | pass | attachments-scope file on qa_vault and a field-owned receipt (showcase_invoice_line.receipt, sys_file stamped ref_object=showcase_invoice_line, ref_field=receipt, acl=private, ref_id = the line): anonymous /files/ID/url and /files/ID → 401 AUTH_REQUIRED, all four |
| download-authz-both-sides | 1 | pass | persona B (no parent read): attachments-scope → 403 ATTACHMENT_DOWNLOAD_DENIED, field-owned → 403 FILE_DOWNLOAD_DENIED, on both routes |
| download-authz-both-sides | 2 | pass | admin → 200 envelope / 302; persona A (entitled on qa_shared) → 200, bytes identical; uploader bypass: C attached to a qa_vault row C owned, admin re-assigned the row (C now gets 404 on the parent) and C's /url still → 200 with identical bytes; minted gated URL exp − now = 300 s (downloadTtl) |
| download-authz-both-sides | 3 | pass | private-parent file: anonymous /files/ID 401 → admin PATCH /data/sys_file/ID {acl:"public_read"} 200 → anonymous /files/ID 302, followed → 200 with identical bytes (pin attachments-public-read-acl also green: flip-back re-closes, per-file, TTL branch) |
| download-authz-both-sides | 4 | pass | browser: A opened qa_shared record → Attachments tab listed x.png; A's grant was then removed server-side and A clicked Download → GET /files/ID/url 403; the panel showed the inline copy "You don't have access to download this attachment." (screenshot), no raw status text. See accuracy note 1 for why step 7's persona B cannot reach the panel |
| read-inherits-parent-rls | 0 | pass | list scoped to qa_vault/vault-1: B 0 rows, A 0 rows, admin 3 rows |
| read-inherits-parent-rls | 1 | pass | with $top: B total=0; A unscoped total=1 (only the qa_shared row); admin total=5 with 2 rows served (count path ran). Pin attachments-parent-rls-count-parity green (6 tests incl. narrowed count = 0 and paging parity) |
| read-inherits-parent-rls | 2 | pass | GET /data/sys_attachment/ID of the vault row: B 404 RECORD_NOT_FOUND, A 404, admin 200 |
| read-inherits-parent-rls | 3 | pass | broad unscoped reads: no invisible-parent row served to B or A; boot log carries zero [storage] attachment read visibility lines (ADR-0139 E7 path, no pre-scan); pin attachments-parent-rls-scan-cap green (3 tests) |
| attach-requires-parent-edit | 0 | pass | A attach to qa_shared → 403 ATTACHMENT_PARENT_ACCESS, sys_attachment total 5 → 5; A's list of the same parent → 200 with 1 row |
| attach-requires-parent-edit | 1 | pass | C attaches to a C-owned qa_shared row with uploaded_by = admin's id → 201; stored uploaded_by = C's id |
| attach-requires-parent-edit | 2 | pass | outsider A deletes admin's attachment on shared-1 → 403 ATTACHMENT_DELETE_DENIED, row still readable by admin. Uploader C on a parent C can READ but no longer EDIT (owner re-assigned; C's PATCH of the parent 403) → DELETE 200 and the row is gone — reproduced twice on two fresh parents |
| attach-requires-parent-edit | 3 | pass | oracle test: attachments-unscoped-delete-gate both blocks green (refused OUTRIGHT on a caller entitled to every row, empty-table refusal, scoped sweep succeeds) |
| attach-requires-parent-edit | 4 | pass | attach to a qa_nofiles row and to a showcase_account row → both 403 FILES_DISABLED |
| attach-requires-parent-edit | 5 | pass | admin PATCH /data/sys_attachment/ID {parent_object:"qa_nofiles", parent_id:…} → 403 FILES_DISABLED "File attachments are not enabled for object 'qa_nofiles' (requires enable.files: true)"; re-read: parent_object/parent_id unchanged |
| sys-file-status-pipeline | 0 | pass | live read sequence: presign → pending; complete → committed; last join delete → deleted (deleted_at set); re-attach → committed, deleted_at null |
| sys-file-status-pipeline | 1 | pass | /files/ID/url while pending → 404 FILE_NOT_FOUND |
| sys-file-status-pipeline | 2 | pass | tombstoned + holder-less → 404 FILE_NOT_FOUND; after attaching a join row → 200 at once. The "row still reads deleted after the download" half is not observable over REST because the hooked attach un-tombstones first (accuracy note 2); that half is cited from tombstone-download-live-reference.test.ts (9 tests green, incl. "serves a tombstoned file that still has a live join row — on BOTH download endpoints" and the sweep-agreement case) |
| sys-file-status-pipeline | 3 | pass | two joins (Website Relaunch + Data Platform) on one file: delete the Data Platform join → still committed; delete the last → deleted |
| sys-file-status-pipeline | 4 | pass | re-attach within the window → committed, deleted_at null |
| sys-file-status-pipeline | 5 | pass | field-owned receipt file attached to and detached from a project: its sys_file stays committed, deleted_at null |
| orphan-tombstone-reap | 0 | pass | oracle test: dogfood describe "sys_file orphan lifecycle (ADR-0057 reap guard)" 5/5 green; the veto variants (non-attachments tombstone with the gate closed, byte-delete failure) are green in attachment-lifecycle.test.ts createSysFileReapGuard |
| orphan-tombstone-reap | 1 | pass | pin "the sweep reaps expired tombstones AND their bytes; fresh tombstones, committed rows and NULL deleted_at survive" green; byte-delete-failure veto green in the unit guard. No REST door triggers the lifecycle sweep, so rule 6's pin citation is the evidence |
| orphan-tombstone-reap | 2 | pass | pin "sweep-time re-verification: a tombstone that regained a reference behind the hooks' back is un-tombstoned, not reaped" green |
| orphan-tombstone-reap | 3 | pass | same reap pin asserts committed and NULL-deleted_at control rows survive |
| upload-session-abort | 0 | pass | live: init with size → 400 INVALID_REQUEST; init with totalSize 6 MiB and requested chunkSize 1 MiB → chunkSize:5242880, totalChunks:2 (floor confirmed); chunk PUT needs the x-resume-token header (without it 403 INVALID_RESUME_TOKEN); after chunk 0 → progress uploadedChunks:1, percentComplete:83, status:in_progress, row in_progress/1 chunk/backend_upload_id set; a full session completed → row completed |
| upload-session-abort | 1 | pass | oracle test: dogfood "(item 4 + multipart-abort guard) an abandoned chunked upload is reaped AND its uploaded parts are aborted" green |
| upload-session-abort | 2 | pass | unit createUploadSessionReapGuard: completed session reaped without abort, no backend_upload_id reaps without abort, adapter without abort reaps — green |
| upload-session-abort | 3 | pass | unit "VETOES on abort failure so backend_upload_id survives for the retry" green (asserts the row is kept and logger.warn called) |
| upload-session-abort | 4 | pass | live statuses observed: in_progress, completed, expired. failed: storage-routes.test.ts "stamps 'failed' on the row when the backend completion throws" + "lets a retried completion overwrite 'failed' with 'completed'" green (not provokable live, accuracy note 3); completing is transient by design (knownGaps) |
| upload-session-abort | 5 | pass | admin back-dated one session's expires_at via PATCH /data/sys_upload_session/ID; chunk PUT → 410 UPLOAD_SESSION_EXPIRED, row reads expired with uploaded_chunks 0 (unchanged); complete → 410; progress → 200; a sibling session inside its deadline stays in_progress |
| inline-grid-receipt-cells | 0 | pass | New Invoice dialog screenshot shows the Line Items grid; header row # · Product * · Description · Service Start · Qty * · Unit Price · Receipt · Amount (one Receipt) |
| inline-grid-receipt-cells | 1 | pass | input[type=file] count 1 in the grid; input[type=text][aria-label=Receipt] count 0 |
| inline-grid-receipt-cells | 2 | pass | picking receipt-qa.png → POST /storage/upload/presigned 200, PUT /storage/_local/raw/… 200, POST /storage/upload/complete 200; cell shows the chip "receipt-qa.png" before submit |
| inline-grid-receipt-cells | 3 | pass | POST /api/v1/batch 200, atomic:true; the line op carries receipt:"1dbbf12c-…" (bare id string); sys_file row committed, ref_object=showcase_invoice_line, ref_field=receipt, acl=private, ref_id = the created line; the line reads back the expanded form {id,name,size,mimeType,url} |
| field-accept-maxsize-server-enforced | 0 | pass | POST /data/qa_media {poster: PDF file} → 400; scoped re-list 0 rows; sys_file ref_id null |
| field-accept-maxsize-server-enforced | 1 | pass | declared size 2097152 into poster → 400; 0 rows; ref_id null |
| field-accept-maxsize-server-enforced | 2 | pass | log: FileConstraintError: File type 'application/pdf' is not permitted by the accept list declared for 'poster' (image/png, image/jpeg) and File exceeds the maximum size declared for 'poster' (2097152 bytes > 1048576 bytes). Measured wire: 400 {"error":"File type 'application/pdf' is not permitted by the accept list declared for 'poster' (image/png, image/jpeg)","code":"ERR_FILE_CONSTRAINT","object":"qa_media"} — the envelope improved over the sanitized-500 the knownGaps predicted (the error now declares status: 400); accuracy note 4 |
| field-accept-maxsize-server-enforced | 3 | pass | PATCH the control record's poster to the PDF file → 400 ERR_FILE_CONSTRAINT; re-read shows the original file |
| field-accept-maxsize-server-enforced | 4 | pass | compliant PNG 10 KB → 201; sys_file ref_object=qa_media, ref_field=poster, ref_id = the record |
| field-accept-maxsize-server-enforced | 5 | pass | same PDF file refused 400 before; admin PATCH /data/sys_file/ID {mime_type:null,size:null} 200 (read-back null/null); same POST → 201 (documented boundary) |
| field-accept-maxsize-server-enforced | 6 | pass | README (no dot) into doc → 201; report.txt into doc → 400 ERR_FILE_CONSTRAINT, 0 rows. Optional step 10: PDF-shaped bytes declared image/png → 201 (declaration-based boundary, not scored) |
Reproduction rules
No fail verdict in this record.
Derived item verdicts
| item | verdict |
|---|---|
| attachments-storage.presigned-upload-roundtrip | pass |
| attachments-storage.download-authz-both-sides | pass |
| attachments-storage.read-inherits-parent-rls | pass |
| attachments-storage.attach-requires-parent-edit | pass |
| attachments-storage.sys-file-status-pipeline | pass |
| attachments-storage.orphan-tombstone-reap | pass |
| attachments-storage.upload-session-abort | pass |
| attachments-storage.inline-grid-receipt-cells | pass |
| attachments-storage.field-accept-maxsize-server-enforced | pass |
Fixture gaps
qa-scratch-authzstep 6 records the persona bindings as intent only; this run had to author three permission sets in the scratch package (bodies above) because the everyone baseline grants members nothing on runtime-authored objects. The payload is now known and could be pinned into the recipe.qa-media-constraintsis now live-proven (it was "not yet proven by a live run").- Cross-tenant clauses stay blocked(dependency) by design (the dogfood
(g)skip); not part of any clause scored here.
Checklist-accuracy notes (closed out on the wave anchor, not extracted)
download-authz-both-sidesstep 7 / clause 4: persona B (no parent read) never reaches RecordAttachmentsPanel — the record page refuses first ("You don't have access to QA vault records"). The reachable shape is a panel opened while entitled, then the grant removed, then Download.sys-file-status-pipelinestep 8 vs step 9: a join row added over REST runs the un-tombstone hook, so the "still deleted after a 200" read in clause 2 can only come from a hook-bypassing system write or the unit pin.upload-session-abortstep 8: "an eTag list the backend rejects" no longer reaches the backend — the route answers 409RESOURCE_CONFLICTnaming the mismatched chunks and the row staysin_progress;failedis only reachable through the test seam.field-accept-maxsize-server-enforcedknownGaps WIRE-ENVELOPE + clause 2: measured 400ERR_FILE_CONSTRAINTwith the prose in the body, not a sanitized 500 — revise per the clause's own instruction.
Generated by Claude Code
Reactions are currently unavailable
Activity
objectstack-fleet commentedon Oct 16, 2026
Close-out for this record (checklist-test §5), 2026-10-16T14:20Z. 9 pass · 0 partial · 0 fail of 9 — nothing red to disposition.
#23932 is the observation met while clause 2 passed: a declared 400 / 422 client refusal is logged as
[REST] Unhandled errorwith a stack. Closed by thedomain:devxseat 1 PM (seat post #6023) ·os-sales·session_014eFvbvGK4FjXvscggDNTD4, as part of the paced close-out of the #23878 wave.Generated by Claude Code