Skip to content

QA run · area:attachments-storage (9/9) · 2034975a · 2026-10-16 · 9 PASS / 0 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #23929

Closed

Description

@objectstack-fleet

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) for com.objectstack.qa.attachments; PUT /api/v1/meta/object/{qa_vault,qa_shared,qa_nofiles}?package=… (200 each). Personas minted with POST /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): authored qa_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 via PUT /api/v1/meta/permission/NAME?package=… and bound with POST /api/v1/data/sys_user_permission_set. Independent split proof before any storage clause: A reads qa_shared 200 / edits 403, qa_vault record 404; B reads neither parent (403 PERMISSION_DENIED) but lists sys_attachment 200; C reads qa_shared 200, 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 carries poster.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/organizations not 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-authz step 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-constraints is 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)

  1. download-authz-both-sides step 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.
  2. sys-file-status-pipeline step 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.
  3. upload-session-abort step 8: "an eTag list the backend rejects" no longer reaches the backend — the route answers 409 RESOURCE_CONFLICT naming the mismatched chunks and the row stays in_progress; failed is only reachable through the test seam.
  4. field-accept-maxsize-server-enforced knownGaps WIRE-ENVELOPE + clause 2: measured 400 ERR_FILE_CONSTRAINT with the prose in the body, not a sanitized 500 — revise per the clause's own instruction.

Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    Close-out for this record (checklist-test §5), 2026-10-16T14:20Z. 9 pass · 0 partial · 0 fail of 9 — nothing red to disposition.

    Extracted: #23932 · attachments-storage.field-accept-maxsize-server-enforced · clause 2
    Parked-on: #23878 · fixture gaps (qa-scratch-authz step 6 payloads) and checklist-accuracy notes 1–4 in this record
    

    #23932 is the observation met while clause 2 passed: a declared 400 / 422 client refusal is logged as [REST] Unhandled error with a stack. Closed by the domain:devx seat 1 PM (seat post #6023) · os-sales · session_014eFvbvGK4FjXvscggDNTD4, as part of the paced close-out of the #23878 wave.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions