Skip to content

QA run · attachments-storage (FULL area) · 92f26f75 · 2026-08-11 · 4 PASS / 4 PARTIAL #7635

Closed

Description

@baozhoutao

Full attachments-storage area run of the checklist-test skill — all 8 items driven against a live showcase (opus subagent, isolated port + file DB, admin + two purpose-provisioned member personas). Text-only per RUNNER.md.

Result: 4 PASS · 4 PARTIAL · 0 FAIL. The security core of this area is genuinely solid — every partial is a surfacing/coverage gap, not a broken gate.

Environment — framework 92f26f75 (branch claude/platform-test-checklist-ocwugl) · vendored console pin 6314e87f · showcase app · isolated port + file DB.

✅ PASS — 4 items

  • presigned-upload-roundtrip (5/5 + both negatives) — presign → PUT → complete → download round-trips with a sha256 match on the retrieved bytes; status moves pending→committed; the /files/:id/url response is the service-storage success bodies are three shapes, none carrying success: true — the other half of #3675 #3689 {success,data:{url}} envelope with no top-level url key; the 302 door resolves to the same bytes. Owner spoofing refused: a presign carrying owner_id:'FORGED-OWNER' and status:'committed' still stored the session user's id and pending. All three upload entry points 401 AUTH_REQUIRED anonymously, and the boot log carries no open-mode notice.
  • read-inherits-parent-rls (4/4 + both negatives) — attachment rows inherit the parent's row-level visibility on both find and count: invisible parent → admin 5 rows / restricted persona 0 (HTTP 200, filtered, nothing leaked); by-id read → 404 RECORD_NOT_FOUND for the restricted persona, 200 for admin. Driven at scale: bulked to 2100 join rows to reach the READ_SCAN_LIMIT branch — the server logs the fail-closed warning and the read stays closed (2095 vs 2100, exactly the 5 withheld rows). A per-row probe found zero rows whose parent the persona cannot read, at both 12-row and 2100-row scale.
  • sys-file-status-pipeline (5/5 + both negatives) — one file driven through every real transition with a separate read after each: pending → committed → (last join row deleted) deleted + deleted_at → (re-attach) committed + deleted_at null. Gated downloads 404 FILE_NOT_FOUND during both the pending and the tombstoned window. The tombstone fires only on the last reference (two join rows over one file: deleting the first changes nothing), and a scope:'user' control file is left untouched by the join-row hooks.
  • orphan-tombstone-reap (4/4 + both negatives) — pins run first per RUNNER rule 6 (15 passed/1 skipped + 25/25), then hand-driven against the platform's own sweep (the LifecycleService initial sweep, not a bespoke sweeper): a 40-day-old tombstone lost both its row and its bytes on disk; a tombstone that regained a reference behind the hooks' back was un-tombstoned, not reaped, with the declared reap-guard log line; a fresh in-window tombstone, a committed row and a pending row all survived untouched. No half-reaped state in either direction.

🟡 PARTIAL — 4 items

1. download-authz-both-sides — server gates are perfect; the console reports a denial as "no attachments"

Server side is fully proven, on both gating classes (attachments-scope and field-owned) and both routes (/files/:id/url and the 302 door): anonymous → 401 AUTH_REQUIRED; two real member personas → 403 ATTACHMENT_DOWNLOAD_DENIED / FILE_DOWNLOAD_DENIED, with their inability to read the parent proven independently first; an entitled non-owner → 200 with matching bytes; the owner bypass on an unreadable parent → 200 for the owner while a sibling file on the identical parent is 403. Flipping acl to public_read opens the anonymous door and restoring private re-closes it. TTL contrast observed: gated grant tokens ≈300 s (downloadTtl) vs ≈3600 s (presignedTtl).

Clause 4 is partial (console surfacing). On a record whose attachment list 403s, the panel renders "No attachments yet. Upload a file to get started." — and still advertises an Upload affordance the server would refuse.

Reproduction rule — as a member denied the parent, open a record with attachments: the panel fires GET /api/v1/data/sys_attachment?...filter=parent_object=…, receives 403, and renders the empty state. Expected denial copy. Actual empty-state copy on a record that in fact holds 2095+ attachments.

The objectui#2532 failure mode (raw error dump / leaked row) is absent — nothing leaks, and no status code is shown. The gap is that a 40x is swallowed into the empty state rather than mapped to denied, so an operator cannot tell "nothing here" from "not yours".

2. attach-requires-parent-edit — gates all hold; one clause is blocked by a stale built @objectstack/core

Passing: a read-but-not-edit persona is refused 403 ATTACHMENT_PARENT_ACCESS on attach while still listing that parent's attachments (read inherits parent READ, attach requires parent EDIT — exactly the contract); uploaded_by is server-stamped over a spoofed value; delete is refused for a non-uploader without parent edit (403 ATTACHMENT_DELETE_DENIED, row still present) and allowed for the uploader even on a parent they cannot edit; deleteMany is all-or-nothing (one refused row aborted the batch, 14→14 rows, both survivors present); an object without enable.files → 403 FILES_DISABLED.

Clause 3 is blocked(environment), and this one needs attention on its own: the predicate-less multi-delete shape cannot be expressed over REST (deleteMany with no ids/where → 400 before the hook is reached), so it rests on its unit pin — and that pin cannot execute in this checkout. All 17 cases in packages/services/service-storage/src/attachment-access-hooks.test.ts error with:

TypeError: withoutOperationPrivateKeys is not a function

because the built @objectstack/core the test resolves is stale relative to packages/core/src/security/operation-private-keys.ts. That is a stale-dist artifact of the prebuilt tree, not a runtime defect — the live server enforced every other gate in this item correctly — but it means the #4757 unscoped-multi-delete refusal is currently unguarded by an executable test here.

3. upload-session-abort — two of the five declared sys_upload_session.status values have no producer

The chunked lane works end to end: init → chunk PUT → progress (percentComplete:50) → complete → committed file → download matches. A wrong resume token is 403 INVALID_RESUME_TOKEN. An abandoned session is reaped with its multipart aborted first, a completed session is reaped without an abort attempt, and the abort-failure veto is pinned green.

Clause 4 is partial. A scan of all session rows after driving both flows returned exactly ['in_progress','completed']. completing is written transiently and immediately overwritten. failed and expired have no writer anywhere in the service — a grep across packages finds them only in the enum declaration and in the retention onlyWhen ({status:{$in:['completed','failed','expired']}}) in system-upload-session.object.ts, plus tests. An expired session's row is reaped by TTL, never re-statused. So the retention rule references two states the system can never enter — an enforce-or-remove candidate.

Clause 3 is also partial on its oracle only: the local adapter's abortChunkedUpload cannot be made to fail from outside the process, so the [storage] reap guard: multipart abort failed line was never emitted live; the veto behaviour itself is pinned and passing.

Contract note: the route takes totalSize (not size) and forces chunkSize ≥ 5 MiB; the item's step text says size.

4. inline-grid-receipt-cells — works correctly; the clause text predates ADR-0104

The grid auto-derives exactly one Receipt column with a real input[type=file] (zero receipt-labelled text inputs — objectui#2360 does not reproduce). Picking a file produces a genuine presign → PUT → complete → thumbnail trace, and submitting the form emits one atomic POST /api/v1/batch with atomic:true and a {"$ref":0} parent reference, persisting both records.

Clause 3 is partial as a checklist-vs-product drift, not a defect. The clause expects a resolved stored-file object carrying receipt.name and an absolute http(s) URL; the shipped payload is the bare opaque sys_file id. What matters was proven: the persisted value is a managed reference, not a data:/blob: placeholder — the sys_file row reads {status:'committed', ref_object:'showcase_invoice_line', ref_id:…, ref_field:'receipt', acl:'private'}, i.e. the ADR-0104 field-ownership claim fired, and the file is correctly gated (anon 401 / two members 403 / admin 200). storage-routes.ts documents the id as "the value a file field stores as a reference". The clause should be restated against ADR-0104 D3.

Fixtures — the item knownGaps were closed, not skipped

The area's declared gap ("no private files-enabled parent exists on stock showcase" — the only enable.files object is public_read_write and readable via the showcase everyone baseline) would have forced blocked(fixture) on the entire authz core. It was closed by authoring scratch objects at runtime (POST /api/v1/packages then PUT /meta/objects/qa_vault?package=… with sharingModel:'private', plus qa_shared public_read and qa_nofiles) and provisioning two real personas via sign-up + a runtime permission-set/position binding. No repo file was touched. Worth landing as a permanent fixture so this area does not need re-provisioning each run.

Checklist maintenance falling out of this run

  1. inline-grid-receipt-cells clause 3 — restate against ADR-0104 D3 (opaque id, not a resolved object).
  2. upload-session-abort clause 4 — either give failed/expired a writer or drop them from the enum and the retention onlyWhen.
  3. attach-requires-parent-edit clause 3 — the pin it depends on cannot execute against the prebuilt @objectstack/core; needs a rebuild in CI or the clause needs a live oracle.
  4. upload-session-abort step text says size; the route takes totalSize.

Activity

  1. baozhoutao commented on Aug 11, 2026

    @baozhoutao
    ContributorAuthor

    Correction — console pin in the Environment line.

    This run's Environment line says vendored console pin 6314e87f. That is the repo-root .objectui-sha (updated 2026-08-11 06:35) — the pin main wants. The bundle the server actually served during this run is packages/console/dist/.objectui-sha = 09987b680d53801c79f67d969b14e9bb732b8a22 (built 2026-08-10 09:58). Root and dist disagree because dist has not been rebuilt against the newer pin.

    No verdict changes. Both console-lane observations here — the attachment panel rendering "No attachments yet" on a 403, and the inline grid's receipt cell — were made by driving the running bundle in a browser, so they describe what actually shipped in this run regardless of which sha is quoted.

    Anyone reproducing the panel empty-state finding should check out 09987b680.


    Generated by Claude Code

  2. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Extraction ledger — COMPLETE. Closing.

    Every PARTIAL, the fixture item and the checklist-maintenance list extracted. This run had 0 FAIL — the storage authz core is genuinely solid, and every card below is a surfacing, coverage or enforce-or-remove gap rather than a broken gate.

    Run item Finding Card Lane
    download-authz-both-sides c4 (PARTIAL) a 403 attachment list is rendered as "No attachments yet" + an Upload affordance the server would refuse — an operator cannot tell "nothing here" from "not yours" objectstack-ai/objectui#4269 objectui
    upload-session-abort c4 (PARTIAL) failed / expired sys_upload_session.status values have no writer anywhere — the retention onlyWhen references two states the system can never enter #7667 domain:services
    attach-requires-parent-edit c3 (PARTIAL) the #4757 unscoped-multi-delete pin cannot execute against the prebuilt @objectstack/core (17 cases error on withoutOperationPrivateKeys) #7668 domain:devx
    inline-grid-receipt-cells c3 (PARTIAL) clause text predates ADR-0104 — expects a resolved stored-file object; the shipped (correct) value is the opaque sys_file id #7669 domain:devx
    Fixture land the runtime-provisioned qa_vault / qa_shared / qa_nofiles + two real personas as a permanent fixture #7670 domain:devx
    Checklist maintenance size → totalSize step-text correction + the cross-reference index #7671 domain:devx

    The 4 PASS items produced no cards.

    #7668's stale-premise check changed its lane, and that matters

    The obvious reading — "a core export is missing" — is wrong. withoutOperationPrivateKeys is exported on origin/main (operation-private-keys.ts:115, re-exported index.ts:145), so this is a build-ordering / CI stale-dist gap, not an engine defect: the pin resolves a @objectstack/core build that predates the source. Filed domain:devx accordingly (CI must rebuild core before running the pin, or the clause needs a live oracle). Had it been filed on the symptom it would have gone to engine-core and wasted a dispatch.

    Worth acting on beyond the cards

    The area's declared knownGap ("no private files-enabled parent exists on stock showcase") would have forced blocked(fixture) on the entire authz core. The runner closed it by authoring scratch objects at runtime and provisioning two real sign-up personas, touching no repo file — which is why this run has real coverage instead of a wall of blocked clauses. #7670 exists so the next run does not have to repeat that.

    Provenance: maintainer standing rule that every QA problem becomes an issue; run records close at extraction, same exit as #7514/#7463/#7627.


    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

    bugSomething isn't workingqa-run

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions