Skip to content

feat(objectql,service-storage): a refused sys_file reader sees a field-owned file's metadata by the download door's verdict (#22637) - #22697

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22637-file-metadata-parent-derived
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22637-file-metadata-parent-derived

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22637
Clause-②: yes

What changes

A reader who may read a record but not sys_file (the default: member_default grants no sys_file read) used to get every file field back as { id, metadataRefused: true } (PR #22620). The download door judges the same file by the record that owns it (ADR-0104 D3) and served that reader the bytes. So the reader could download the file but could not see its name.

This executes the maintainer's ruling B on #22624, verbatim as the card records it:

A file field's metadata follows the parent-derived verdict the download door already applies (ADR-0104 D3, buildFileReadAuthorizer): when the caller's sys_file read is refused, resolveFileReferences still hydrates a file whose ref_object / ref_id name the record being read (the delegate consulted where the owner object declares a fileAccessDelegate); a referenced id that does not point at that record keeps PR #22620's refused marker; direct sys_file queries and public_read are unchanged.

  • From. A reader with read on contract and no sys_file read gets { "signed_pdf": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }.
  • To. For a file whose ref_object / ref_id name the record being read, that reader gets { id, name, size, mimeType, url }, exactly what a sys_file reader gets, when the door's field-owned verdict allows. A delegate-governed object gets the delegate's answer.
  • Unchanged.
    • Every other id keeps the marker: an id copied in from another record, an attachment-only or unclaimed file, a public_read file the record does not own, and an owned file the verdict refuses.
    • sys_file's own read rule: a direct sys_file query by the same reader is still PERMISSION_DENIED (403).
    • public_read handling.
    • A sys_file reader's hydration.
    • The download door's verdict for every request.
    • An engine with no verdict registered.

One verdict, two callers

ObjectQL holds no service registry (no getService or kernel reference in engine.ts), so the engine cannot reach a declared fileAccessDelegate itself. Copying "delegate declared, so ask it and fail closed; otherwise read the record" into the engine would be the second predicate the ruling forbids. The verdict stays where it is and is handed over, the same way registerHeldFileResolver already hands over the tombstone holder question:

  • service-storage (storage-service-plugin.ts)
    • The field-owned arm of buildFileReadAuthorizer (the :1237-:1265 range the ruling cites) moves, unchanged, into one module-private batched function, readableFieldOwners(registry, engine, ownerObject, ownerIds, authz). It is not exported from the package.
    • The door calls it with its one owner id. The query is byte-identical (where: { id }, fields: ['id'], limit: 1, any row allows), and so is the fail-closed handling of the delegate.
    • start() registers the same function on the engine, beside registerHeldFileResolver, through the new duck-typed seam registerFieldOwnedFileReadAuthorizer.
  • objectql (engine.ts)
    • The new seam is registerFieldOwnedFileReadAuthorizer (type FieldOwnedFileReadAuthorizer, exported).
    • On the refused arm of resolveFileReferences, readParentDerivedFiles makes two reads.
      1. One sys_file read in a sudo()-shaped context ({ ...caller, isSystem: true }, so the tenant wall and the transaction ride along). Its where asks for the ownership pair itself (id $in, ref_object this object, ref_id $in this result's record ids) and projects only seven columns.
      2. One call to the registered verdict with every owner id, under the caller's envelope minus its operation-private __ keys (withoutOperationPrivateKeys).
    • Allowed rows go through the same servability rule as the normal path (servableFileRows, extracted, shared) and render through one toFileValue.
    • Judged per element and per record: an id hydrates only on the record whose field owns it.
    • Unwired, a projection without id, or any failure: every id keeps the marker. A failure logs one warn.

Is the ownership match a second predicate? No, by the ruling's own words. "Whose ref_object / ref_id name the record being read" is the ruling's scope condition, not an access verdict. The verdict is reached without a copy: the door's own function decides allow or deny, including the delegate. The system read is the posture the door already reads a file in (by id, system context, never the caller's sys_file grant). It serves only ids a record the caller just read holds.

The read is deliberately narrower than the door, by the ruling. The door also serves a file's uploader and any file reachable through a sys_attachment parent. Hydration serves only a file owned by the record being read.

Measured on main first

The test was a scratch file in plugin-security, never committed and deleted after the run. It used real ObjectQL (source), the real SecurityPlugin, the real StorageServicePlugin and SqlDriver. The reader had read on the record's object and no sys_file read. Two committed files were seeded: one field-owned by record d1, and one owned by d9 and copied into d2.

  • With engine.ts and storage-service-plugin.ts at 243dd3c625 (blobs 3177422431 and 0246a5bfe4): d1 read { id, metadataRefused: true }, d2 read { id, metadataRefused: true }, and a direct sys_file query got PERMISSION_DENIED / 403. The premise holds on main.
  • At this branch: d1 read { id, name: 'contract.pdf', size: 1024, mimeType: 'application/pdf', url }, d2 still read { id, metadataRefused: true }, and the direct query still got PERMISSION_DENIED / 403.
  • The two files were restored from HEAD: blob equals HEAD, git diff HEAD is empty, and the porcelain status is clean.

Pins

  • packages/services/service-storage/src/field-owned-file-hydration-door-agreement.test.ts (new). Both surfaces are asked about one fixture: the door is GET /files/:fileId/url as the reader, and the record read runs as the same reader. The fixture is one real ObjectQL + SqlDriver, with StorageServicePlugin booted and wired as it ships.
    • A field-owned file on the record read: the door returns 200 and the read hydrates in full.
    • A fileAccessDelegate that refuses: the door returns 403 FILE_DOWNLOAD_DENIED and the read keeps the marker. A delegate that allows: the door returns 200 and the read hydrates, and the delegate was asked by the reader for the owning record. Both decisions are readable by a raw record read, so the delegate is what decides.
    • An id copied out of a record the reader cannot read: refused on both surfaces.
    • The ruling's boundary: an id copied from a record the reader can read hydrates on its owner and keeps the marker where it was copied. An attachment-only file keeps the marker.
    • public_read is unchanged: anonymous door 200, and the refused reader's read keeps the marker.
    • A direct sys_file query is still refused. A multi-value field is judged per element.
    • Stand-ins: the sys_file refusal (middleware throwing PERMISSION_DENIED, the shape plugin-security's file-field-hydration-refused.test.ts measures on the real layer), the permission store (unregistered objects read empty), and authentication by header.
  • packages/objectql/src/engine-file-hydrate-parent-derived.test.ts (new) covers the engine's half with a recorded stand-in verdict:
    • per-record pairing, mixed arrays, another object's same id, and a refusing verdict;
    • the verdict is asked once, with owner ids only and no __ keys, and exactly one system sys_file read is made, with the ownership pair in its where;
    • an owned tombstone (held) and a pending upload, each read as a sys_file reader reads it, and a projection without id;
    • unwired, a failing system read and a throwing verdict (marker plus one warn);
    • controls: a sys_file reader, a direct sys_file query, an empty field.
  • Unedited and green: the door's existing tests (mount-storage-routes, storage-routes, file-read-tenancy-posture-admission, storage-routes.authz-outage-relay and the rest of service-storage), plus fix(objectql): a refused sys_file read marks the file field refused instead of reading as no file #22620's engine-file-hydrate-refused.test.ts and plugin-security/src/file-field-hydration-refused.test.ts.

Ablation

node scripts/ablation-replace.mjs (WRAP mode) replaced the anchor const owned = await this.readParentDerivedFiles(objectName, records, uniqueIds, execCtx); with the same call over records.slice(0, 0) plus a debug marker line. That removes the parent-derived arm. The anchor hit once (1 to 0), and the blob changed from 6a9455a5d3 to bb576869bc.

  • objectql (the test imports ./engine, so it reads source): 10 failed and 6 passed. The first pin received { id: 'f_owned_1aa', metadataRefused: true }. The failing-read and throwing-verdict cases went red too: with the arm gone, nothing is read, so their warn never comes.
  • service-storage (@objectstack/objectql resolves to dist/, a KNOWN_UNALIASED_TEST_IMPORTS pair): objectql was rebuilt with the mutation, and ablation-dist-preflight found the marker in 4 built files. Result: 4 failed and 5 passed. The field-owned pin received { id, metadataRefused: true }, and the delegate-allow, owner-side-of-a-copy and multi-value pins failed too. The refusal-side pins stayed green, as they should.
  • Restore. The blob equals HEAD (6a9455a5d3), git diff HEAD is empty, and the porcelain status is clean. After a rebuild, ablation-dist-preflight --absent passed (marker absent from all 14 built files, tree clean), and both files went green again: 16 of 16 and 9 of 9.

The ablation ran at e6e7fbec66. The merge of origin/main that followed did not change engine.ts, the service-storage package or either new test (the git diff --stat is empty).

Verification at 9931bb55d3 (head, origin/main merged)

  • pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 394 files, 7753 tests passed. --project repo: 1 file, 5 tests passed.
  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2: 51 files, 846 tests passed.
  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/file-field-hydration-refused.test.ts: 4 passed.
  • typecheck: @objectstack/objectql passed (exit 0; test layer 40 files / 234 errors / 65 pinned signatures, unchanged), and @objectstack/service-storage passed (exit 0; test layer clean).
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) derives 102 commands. All 102 ran after a full turbo run build, each with its exit code captured before any pipe, and all exited 0. --ran reconciles them as 102 derived, 102 run, 0 NOT MEASURED. Two red results on the first pass were real and are fixed: check:query-options-erasure (my tests erased query options, 236 to 240, now 236) and check:slot-lookup (a getService typed any in my test).
  • Lint, a narrowed run: eslint --no-inline-config --format json over the 5 changed .ts files reports 5 files, 0 errors and 0 warnings.
    • Population: eslint.config.mjs, the config pnpm lint (eslint .) runs.
    • Invariance: the config enables no type-aware linting (no parserOptions.project), so this diff cannot move any untouched file's verdict.

Cross-lane paths

Declared before any edit:

No generated page and no content/docs/releases/ path.

Acceptance notes

  • FileRefusedValueSchema's TSDoc (packages/spec, "when the reader is refused the sys_file read that resolves it") is left as it is. It is now a slight overstatement: the marker means the read was refused and the parent-derived verdict did not allow it. Changing it would pull in gen:schema / gen:docs. The same holds for fileAccessDelegate's TSDoc and .describe() ("authorizes downloads"). The delegate is now also consulted for a refused reader's metadata. Neither is false; both are incomplete.
  • Narrower than the door. An uploader without sys_file read, reading a delegate-governed record whose delegate refuses them, sees the marker although the door serves them the bytes (the door's owner arm). The ruling cites the field-owned arm only.
  • A projection that leaves out id cannot name its owner record, so every id keeps the marker. Not measured: whether any shipped caller projects file fields without id.
  • Cost. A refused hydration with owned ids makes one more system sys_file read and one verdict call: one batched owner read, or one delegate call per owner. The non-delegate arm re-reads records the caller has just read. That is redundant by construction (the ruling's own reduction), and it is the price of one predicate.
  • No committed pin drives the full composition (real SecurityPlugin + StorageServicePlugin + REST). The scratch measurement above did, uncommitted. A dogfood pin would live in packages/qa (domain:cli) and is not in this PR. Carrier: none.

Generated by Claude Code

… field-owned verdict for the files the record owns

WIP: engine seam (registerFieldOwnedFileReadAuthorizer) and the refused arm's
parent-derived half.

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
Co-authored-by: Claude <noreply@anthropic.com>
…field-owned verdict

The field-owned arm of buildFileReadAuthorizer moves into one module-private
batched function, readableFieldOwners; the door asks it for its one owner and
start() registers it on the engine through registerFieldOwnedFileReadAuthorizer.

Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/service-storage, touching 18 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/plugin-endpoints.mdx (via sys_file (literal, a string literal in readParentDerivedFiles), /files/:fileId (route, a path literal in a comment on a changed line))
  • content/docs/automation/approvals.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/automation/webhooks.mdx (via ref_id (literal, a string literal in PARENT_DERIVED_FILE_COLUMNS))
  • content/docs/deployment/cli.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/kernel/contracts/storage-service.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/permissions/attachments-access.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/protocol/objectql/types.mdx (via ref_id (literal, a string literal in PARENT_DERIVED_FILE_COLUMNS), ref_object (literal, a string literal in PARENT_DERIVED_FILE_COLUMNS), sys_file (literal, a string literal in readParentDerivedFiles), /api/v1/storage/files (route, a path literal in toFileValue), /files/:fileId (route, a path literal in a comment on a changed line))
  • content/docs/ui/translations.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/releases/index.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/releases/v15.mdx (via sys_file (literal, a string literal in readParentDerivedFiles), /files/:fileId (route, a path literal in a comment on a changed line))
  • content/docs/releases/v16.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/releases/v17/17-0.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/releases/v17/17-5.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))
  • content/docs/releases/v17/index.mdx (via sys_file (literal, a string literal in readParentDerivedFiles))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e250d8f84b2fb154692715a066a95a4f00cbd182 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c8a7437e95e190c8107a029cad331b9b35aeba7f — the merge of head 9931bb55d34e72a9d0fc784dcba2d59dbc94f89d into base e250d8f84b2fb154692715a066a95a4f00cbd182, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c8a7437e95e190c8107a029cad331b9b35aeba7f && git checkout c8a7437e95e190c8107a029cad331b9b35aeba7f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e250d8f84b2fb154692715a066a95a4f00cbd182 9931bb55d34e72a9d0fc784dcba2d59dbc94f89d && git checkout -B drift-repro e250d8f84b2fb154692715a066a95a4f00cbd182 && git merge --no-ff 9931bb55d34e72a9d0fc784dcba2d59dbc94f89d

node scripts/docs-audit/affected-docs.mjs --json e250d8f84b2fb154692715a066a95a4f00cbd182

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e250d8f84b2fb154692715a066a95a4f00cbd182 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9931bb55d34e72a9d0fc784dcba2d59dbc94f89d
Local-runs: none

Reading time 2026-10-10T15:30Z. Read-only throughout: card #22637 (body; triage 6095345451; claim 6097826006; os-dev-report 6098964328), decision card #22624 and ruling 6094990917, ADR-0104 D3 on main, the cross-lane declarations 6097907456 (#6021) and 6098008938 (#6023), PR #22697 (body, file list, and the compare diff of the head against main, merge-base d973c34cdd), the head's check-runs, and the source at the head and at the merge-base through the REST API plus a blobless scratch clone under this seat's scratch space (a read; nothing was built, run, re-run or ablated here).

Gate verdicts on the head. 35 check-runs, all concluded: 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke, both opt-in or path-scheduled). All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The file list (8 paths) touches no governed surface; head repo equals base repo; 1,316 changed lines.

① Derived judgments

Each item names what the diff implies and whether it is right against the ruling's words (a refused reader is served the metadata of a file whose ref_object / ref_id name the record being read, by the download door's own parent-derived verdict, the delegate consulted where the owner object declares fileAccessDelegate; every other id keeps PR #22620's marker; direct sys_file queries and public_read unchanged; no second predicate) and ADR-0104 D3 ("field-referenced files get parent-derived read checks, reusing the attachments authorizeFileRead verdict model"; the expanded form "produced at read/expand time from the sys_file row").

  1. The engine's refused arm and its system-context sys_file read: RIGHT. On isReadRefusal only (the PERMISSION_DENIED code; a database ACL fault still takes the outage arm), readParentDerivedFiles makes one sys_file read under { ...caller, isSystem: true }, the exact shape of ScopedContext.sudo() in engine.ts, so the caller's tenantId (the driver tenant scope in buildDriverOptions) and transaction ride along and the security layer's explicit system opt-in short-circuits the CRUD gate (security-plugin.ts, the "explicit system opt-in" predicate). Its where is the ownership pair itself (id $in the ids the result holds, ref_object this object, ref_id $in this result's record ids) and it projects seven columns (PARENT_DERIVED_FILE_COLUMNS), so a file not field-owned by a record in the result is never read. The caller's envelope is forwarded to the verdict stripped of __ keys (withoutOperationPrivateKeys, core). The rows the verdict allows go through the one servability rule (servableFileRows, extracted from the normal arm unchanged) and the one renderer (toFileValue, the former inline toValue), so both arms hand out one shape. Failure of the system read or of the verdict: every id keeps the marker, the record read still succeeds, one warn (functional degradation; check:durability-log-level green). Pinned: the system read happens once, its where carries the pair, the verdict is asked once with owner ids only and no __ key, the failing-read and throwing-verdict cases keep the marker with one warn. Observation, not a defect: a principal-less context (refused by the security layer for carrying no principal) reaches this arm, performs the system read, and the verdict then returns nothing because the envelope has no userId; the read's result is discarded. Cost only, nothing served.
  2. The ownership match is the ruling's scope condition, not a second predicate: RIGHT. The ruling itself reduces the verdict at hydration to "the file's reference points at this record, with the delegate consulted where declared". The engine copies no access rule: the where and the per-row ref_object / ref_id re-check select WHICH ids are put to the verdict; allow and deny, delegate and fail-closed included, come from the storage package's function. The non-delegate arm re-reads the owner record as the caller, which the ruling says reduces to the read already made; the dev kept the door's own call instead of short-cutting it, which is one predicate applied unchanged, and the redundancy is named in the acceptance notes.
  3. The seam registerFieldOwnedFileReadAuthorizer and the exported type FieldOwnedFileReadAuthorizer: RIGHT. The engine declares the shape (owner object, owner ids, context, returning the readable subset) and never implements it; StorageServicePlugin.start() fills it beside registerHeldFileResolver, duck-typed so an older engine keeps the marker; last registration wins; the storage function is module-private (no export). The engine package gains one public method and one exported type (index.ts), nothing else on its surface. ObjectQL holds no service registry, so this handover is the way to reach a declared fileAccessDelegate without a copy.
  4. readableFieldOwners: the door's verdict is byte-for-byte unchanged for every request, delegate and fail-closed paths included: RIGHT, verified against the merge-base source. The door's field-owned arm now calls it with the one owner id. Four textual differences exist and none changes a reachable verdict: (a) where: { id: ownerIds[0] } where ownerIds[0] is String(file.ref_id), against the old where: { id: file.ref_id }; sys_file.ref_id is a text column and FileRecord.ref_id is string | null, so the value is identical; (b) the new userId guard returns the empty set, and the door already answers 'unauthenticated' before this arm when authz.userId is absent, so the guard is dead for the door and only binds the hydration caller, in the door's own direction; (c) limit: ownerIds.length is 1 for the door; (d) the delegate's getService throw, a missing or non-function delegate, a falsy answer and a thrown authorizeFileRead each deny, exactly as the single try did. The owner arm (uploader allowed) and the attachment-parent arm are untouched; storage-routes.ts (authorizeDownload, the public_read carve-out) is not in the diff. The door's existing tests are unedited and Test Core is green on the head; the new agreement test asks the real door (GET /files/:fileId/url) and the real record read about one SqlDriver fixture with the plugin booted as it ships.
  5. The unwired engine keeps the marker: RIGHT. readParentDerivedFiles returns before any read when no authorizer is registered; pinned (every id refused, zero system reads). The unedited plugin-security pin (file-field-hydration-refused.test.ts, real SecurityPlugin, no storage plugin) is that engine and stays green.
  6. multiple fields and mixed arrays: RIGHT. Judged per element and per record: an owned id hydrates on its owner only, the same id copied into another record of the same object keeps the marker there, an id owned by a record of another object with the same id keeps the marker, a copied, attachment-only or public_read element keeps the marker, a legacy URL element passes through. Pinned in both test files (gallery of four; annexes of five).
  7. Nothing widens sys_file's own read rule, a direct sys_file query, or public_read: RIGHT. The caller's own sys_file read is the unchanged first read and is still refused; the system read is engine-internal, scoped to the ownership pair, and nothing it reads reaches the caller unless the door's verdict allows the owner; a direct sys_file query by the refused reader is pinned PERMISSION_DENIED 403 in both test files; public_read is pinned anonymous 200 at the door and the marker in the refused read for a public_read file the record does not own. A public_read file the record does own hydrates by the verdict, which is the ruling's rule, not a change to public_read.
  8. An owned, allowed row that is not servable reads as the bare id: RIGHT, noted. A pending upload and an unheld tombstone read for the refused reader exactly as they read for a sys_file reader (pinned against the system read). The ruling serves "exactly as a sys_file reader" and servability is not an access verdict; the types.mdx edit keeps the bare id's documented meaning (not provisioned, or an outage).
  9. Re-entry terminates. The verdict's owner re-read is projected to fields: ['id'], so its result carries no file field and resolveFileReferences returns before any sys_file read (the engine planner keeps id in a projection; the memory driver's projectFields and SqlDriver honour fields). The shipped delegate (approvals) reads the action under its own system context with fields: ['request_id'] and the request as the caller, and sys_approval_request declares no media field (its attachments are action params), so a delegate-governed refused read makes no unbounded loop.
  10. Docs: RIGHT. Two hand-written passages (protocol/objectql/types.mdx file read forms; permissions/attachments-access.mdx refused-reader clause) gain the parent-derived case, prose only; no generated page, no content/docs/releases/ path.

② Semver level

.changeset/22637-file-field-metadata-parent-derived.md: @objectstack/objectql: minor, @objectstack/service-storage: minor, body carries Clause-②: yes, no ADR-0087 marker (nothing an author writes is removed or renamed; not breaking). That matches what the diff publishes: objectql gains a public method, an exported type and the widened read; service-storage gains the engine registration and a verdict-preserving extraction. The two docs files publish nothing; packages/spec is untouched, so no spec changeset and no regenerated artifact is owed. Clause-②: yes stands on the PR body, the changeset and the card, and is right: a widening of what a refused reader is served; yes takes at least minor, satisfied. No skip-changeset. Check Changeset, check:adr-0087-registration and check:empty-changeset are green on the head.

③ Boundary flags

Dev deviations, each answered:

  • Cross-lane edits beyond packages/objectql: declared before any edit, verified on the timeline. The domain:services declaration landed at 2026-10-10T13:18Z and the first service-storage commit (4525b2e2d5) at 2026-10-10T13:31Z; the domain:devx declaration at 2026-10-10T13:30Z and the docs commit (1932869678) at 2026-10-10T13:32Z. The declared paths are exactly the paths edited (two service-storage files; two docs files). Answered.
  • Two first-pass gate reds from the dev's own test code, fixed: Lint & Repo Gates is success on the head. Answered.
  • origin/main merged before pr_create: main has since moved 9 commits past the merge-base; none touches packages/objectql, packages/services/service-storage, packages/core/src/security or the two docs; the one plugin-security source change (security-plugin.ts, +20/-1, capability declarations) does not touch the system opt-in or the sys_file gate. The queue's rebuild covers the merge commit. Answered.
  • Foreground CI polling: logistics only. Answered.

out_of_scope_findings, each answered or escalated:

  • Spec TSDoc now incomplete (FileRefusedValueSchema; fileAccessDelegate TSDoc and .describe()): ESCALATED to the dispatching seat, as a filed card, not a note. FileRefusedValueSchema still says "Nothing else is served, because nothing else was read"; after this PR the marker also means "the parent-derived verdict did not allow", and for an owned, verdict-refused file the engine did read the row. fileAccessDelegate's .describe() ("authorizes downloads of files owned by this object's media fields") now undersells the delegate: it is also asked, once per owner, on every refused record read of a delegate-governed object, with the caller's execution context. That text is what an author reads when declaring the delegate, so under Prime Directive 10 it is a metadata-authoring trap to file, and the fix is a .describe() change plus gen:schema, gen:docs in domain:spec, correctly left out of this diff. It does not make this PR wrong. Seat action: file the domain:spec card (dedupe words as the report gives them).
  • No committed full-composition pin (real SecurityPlugin + StorageServicePlugin + REST): ANSWERED, accepted. The committed pins cover the engine's half (fake driver, recorded verdict) and the door agreement (real engine, SqlDriver, the real plugin, stand-in refusal of the measured shape); the one fact only the scratch run measured, that the sudo-shaped read passes the real CRUD gate, holds by reading of the security plugin's system opt-in. A packages/qa dogfood pin is domain:cli and optional; not a merge condition.
  • Narrower than the door for an uploader whose delegate refuses them: ANSWERED, within the ruling. The ruling cites the field-owned arm (:1237-:1265) by name, and the owner arm precedes it; the hydration being narrower there is the ruling as written, not a deviation. Widening to the owner arm would be a new decision card, not this PR's defect.

Nothing else in the diff falls outside the card's scope. The PR is a draft; after this record the owning seat flips it ready and arms auto-merge on green.

Implemented-by: claude/issue-22637-file-metadata-parent-derived
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 15:33
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 15:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 1bb1da1 Oct 10, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22637-file-metadata-parent-derived branch October 10, 2026 15:58
os-project-manager pushed a commit that referenced this pull request Oct 10, 2026
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants