Repository navigation
Commit 02d9f69
fix(objectql): a refused sys_file read marks the file field refused instead of reading as no file (#22620)
Fixes #22593
Clause-②: yes
## What changes
A file field (`file` / `image` / `avatar` / `video` / `audio`) holds a
`sys_file` id. The engine expands that id by reading `sys_file` as the
caller (`ObjectQL.resolveFileReferences`,
`packages/objectql/src/engine.ts`). A reader who may read the record but
not `sys_file` gets that sub-read refused. The record still came back,
but with the bare id in the field. A bare id is also what an uncommitted
id or a storage outage leaves behind, so the console showed "no file"
and the server logged a `warn` telling the operator to check storage.
After this change, the security layer's refusal (`PERMISSION_DENIED`)
marks each held id as refused, in the value itself:
- **From:** `{ "attachment": "f_7cQx2Lm90a" }`, plus `warn: sys_file
lookup failed; file fields keep their raw ids and will render as "no
file" for this read`.
- **To:** `{ "attachment": { "id": "f_7cQx2Lm90a", "metadataRefused":
true } }`. Multi-value fields mark each id, and a legacy URL string in
the array passes through unchanged. The refusal is logged at `debug`
only, because the reader is told in the value (AGENTS "Degradation log
levels": a failure handed to the caller).
- **Unchanged:**
- A reader with `sys_file` read still gets the hydrated `{ id, name,
size, mimeType, url }`.
- An empty field still reads empty.
- A missing `sys_file` table still leaves the bare id, silently.
- Any other failure (connection, timeout, a database-level ACL fault
such as `42501`) still leaves the bare id with the existing `warn`.
- The record read still succeeds. Fail-open is unchanged.
Nothing that was not read is served: the marker has no `name`, `size`,
`mimeType` or `url`. A consumer that needs a link builds the stable
`/api/v1/storage/files/:fileId` endpoint from the id, as it already does
for a bare id. The download door makes its own access decision (see "Not
in this PR").
`@objectstack/spec` declares the shape. `FileRefusedValueSchema` /
`FileRefusedValue` is a closed `{ id, metadataRefused: true }`, an
ADR-0122 isomorphic alias pinned in `type-alias-convention.pin.test.ts`
(773 to 774). `valueSchemaFor(def, 'expanded')` for the file types now
accepts it beside the bare id and `FileValueSchema`. It is a read form
only: the stored form stays the bare id, and a pin checks that
`valueSchemaFor(def)` (the stored form) rejects the marker object.
Changeset: `@objectstack/objectql` minor and `@objectstack/spec` minor,
with the FROM and TO above.
## Measured on main first (the symptom, before the change)
Both pin files were run against `main`'s `engine.ts` (`d748ae80af`, blob
`df2300664b`, byte-identical by `git hash-object`), with every other
file at this head (`faf689872c`). The file was then restored from `HEAD`
(blob `1d1b78f500`, and `git diff HEAD` is empty).
- Real security layer
(`plugin-security/src/file-field-hydration-refused.test.ts`, real
`ObjectQL` + `SecurityPlugin` + `SqlDriver`): the reader with record
read and no `sys_file` read got `attachment === 'f_7cQx2Lm90a'`, the
bare id. 1 failed and 3 passed. The 3 that passed are the two controls
and the direct-refusal probe, which shows `sys_file` refused with
`PERMISSION_DENIED` / 403.
- Seam pins (`objectql/src/engine-file-hydrate-refused.test.ts`): 4
failed and 4 passed. The `warn` captured on main is exactly the line
from the report: `sys_file lookup failed; file fields keep their raw ids
and will render as "no file" for this read`. The 4 controls passed on
main as well.
The symptom from the 17.7.0 report reproduces on current `main`.
## Pins (green at `faf689872c`)
- A reader without `sys_file` read sees `{ id, metadataRefused: true }`,
through both `find` and `findOne`, on the real security layer and on the
seam.
- Control: a record with no file still reads `null`.
- Control: a reader with `sys_file` read gets the hydrated file.
- Control: a missing `sys_file` table (`42P01`) still reads the bare id,
with no `warn`.
- Control: a database-level ACL fault (`42501`) is an outage, not a
refusal of this caller. It still reads the bare id and still logs the
seam's `warn`.
- The served value parses as `FileRefusedValueSchema` and as
`valueSchemaFor(def, 'expanded')`, and carries exactly the keys `id` and
`metadataRefused`.
## Ablation
`node scripts/ablation-replace.mjs` replaced the anchor `if
(isReadRefusal(error)) {` with `if (false && isReadRefusal(error)) {`,
which restores the fall-through.
- The anchor hit once (count 1 to 0), and the blob changed from
`1d1b78f500` to `1565ec17ca`.
- objectql pins: 4 failed and 4 passed. The first pin received the bare
id.
- plugin-security pins: 1 failed and 3 passed. The first pin received
the bare id.
- Restore: the blob equals `HEAD` (`1d1b78f500`), `git diff HEAD` is
empty and the porcelain status is clean.
Both legs read source, not `dist/`: the objectql test imports
`./engine`, and plugin-security's `vitest.config.ts` aliases
`@objectstack/objectql` to `../../objectql/src/index.ts`.
## Cross-lane paths (the seat declares these on the lane posts before
this PR leaves draft)
- `packages/spec/src/data/field-value.zod.ts`, its pins
(`field-value.test.ts`, `type-alias-convention.pin.test.ts`, and
`scripts/strictness-ledger.test.ts`, whose site count over
`field-value.zod.ts` moves from 3 to 4) and the regenerated spec
artifacts (`api-surface/`, `authorable-surface/`, `declaration-map/`,
`export-origins/`, `json-schema.manifest/` data shards) are
`domain:spec`.
-
`packages/plugins/plugin-security/src/file-field-hydration-refused.test.ts`
(test only) is `domain:services`.
- `content/docs/references/data/field-value.mdx` and
`content/docs/references/index.mdx` (generated by spec `build-docs`),
and the hand-written `content/docs/protocol/objectql/types.mdx` (the
`file` read forms) and `content/docs/permissions/attachments-access.mdx`
(one sentence), are `domain:devx`.
- `docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md`
(generated, `check:strictness-ledger`) gets +1 strict site in `data/`.
`docs/audits/2026-07-unknown-key-strictness-ledger.md` gets a one-row
update on the `field-value.zod.ts` row naming the third `strictObject`.
## Consumer relay (objectui, not edited here)
At objectui `origin/main` `12ff256`,
`packages/fields/src/widgets/file-value.ts` `readFileValue` takes the
marker through its object arm:
- The id is kept, and the URL is built from the id (`fileUrlFromId`).
- The name falls back to the generic "File", and there is no `mimeType`.
- `metadataRefused` is not carried onto `FileValueView`.
So today the console already shows a non-empty generic file chip, not
`EmptyValue`, but the refusal itself never reaches the UI. What the
console's file renderers (`FileField` read-only list, `FileCell`,
`ImageField`, `AvatarField`, the grid cell) should show for
`metadataRefused: true`:
- A distinct restricted state: a lock affordance and a translated label
(for example "File (details restricted)"), not the generic fallback
name.
- Never `EmptyValue`.
- No thumbnail attempt for image or avatar types, because no MIME type
is known.
- Keep the open/download affordance on the id-derived stable endpoint.
That door decides on its own and may answer `403 FILE_DOWNLOAD_DENIED`.
- The editable widget submits only the bare id (`fileIdOf` already reads
`id`), never the marker object.
Once objectui takes a spec that carries `FileRefusedValueSchema`, it can
tell the form apart by shape.
## Not in this PR
- **The access half.** This PR makes no change to `sys_file`'s read
rule. Whether file metadata should follow the read access of the parent
record goes to the maintainer on a separate decision card, which the
seat files. The inputs were measured read-only and are in the report on
the card.
- Row-level narrowing on `sys_file` (a reader with object read whose
sub-read comes back short) still reads as a bare id. The engine cannot
tell a hidden row from an uncommitted one without an existence probe,
and an existence probe is itself an access decision. It belongs to the
access half.
## Acceptance notes
- Export: `formatCsvCell` already serialises an object-valued cell as
JSON, so a refused reader's CSV cell now reads
`{"id":"…","metadataRefused":true}` instead of the bare id. This is
distinct from both empty and hydrated. XLSX and JSON export pass the
value through.
- `plugin-approvals` `normalizeActionAttachment` would drop the marker
(it maps any object to `{ id, name?, url? }`), but its only caller,
`listActions`, reads `sys_approval_action` with the system context, so
its hydration never sees a refusal.
- The header of
`packages/services/service-storage/src/attachment-access-hooks.ts` still
says the default member permission sets grant wildcard CRUD.
`member_default` has named no `'*'` since that wildcard was removed, so
the comment is stale. This is noted only, not filed (carrier: none).
## Verification (head `faf689872c`, which is `origin/main` `d748ae80af`
merged in)
- Build: `pnpm --filter '@objectstack/plugin-security...' build`, which
covers spec, objectql and plugin-security in one closure. Then `pnpm
--filter @objectstack/spec check:generated` reported all 15 artifacts up
to date.
- Full suites:
- `@objectstack/objectql` `test`: 393 files and 7738 tests passed.
- `@objectstack/plugin-security` `test`: 194 files, with 4079 passed and
45 skipped.
- `@objectstack/spec` `test`: 640 files, with 19103 passed and 1 todo.
- `typecheck` passed for spec, objectql and plugin-security.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 119 commands, and all 119 exited 0. The `--ran`
reconciliation reads: 119 derived, 119 run, 0 NOT-MEASURED, all with
recorded exit codes.
- Narrowed lint over the 6 changed TypeScript files, with `eslint
--no-inline-config --format json`:
- The `--format json` output reports 6 files, 0 errors and 0 warnings.
- The population comes from the `eslint.config.mjs` blocks for
`packages/**/*.{ts,tsx,mts,cts}`.
- The config never enables type-aware linting, so this diff cannot
change the verdict on any file it does not touch.
- The repository-wide `pnpm lint` is left to CI.
- Not run locally, left to CI: the 6 path-scheduled CI jobs and the
workspace type-check lanes that the derivation lists as out of reach
locally.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3d0eeef commit 02d9f69
19 files changed
Lines changed: 737 additions & 61 deletions
File tree
- .changeset
- content/docs
- permissions
- protocol/objectql
- references
- data
- docs/audits
- 2026-07-unknown-key-strictness-ledger.counts
- packages
- objectql/src
- plugins/plugin-security/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- scripts
- src
- data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
165 | 165 | | |
166 | 166 | | |
167 | 167 | | |
168 | | - | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
169 | 171 | | |
170 | 172 | | |
171 | 173 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1156 | 1156 | | |
1157 | 1157 | | |
1158 | 1158 | | |
1159 | | - | |
1160 | | - | |
| 1159 | + | |
| 1160 | + | |
| 1161 | + | |
| 1162 | + | |
| 1163 | + | |
| 1164 | + | |
| 1165 | + | |
| 1166 | + | |
| 1167 | + | |
1161 | 1168 | | |
1162 | 1169 | | |
1163 | 1170 | | |
1164 | | - | |
| 1171 | + | |
1165 | 1172 | | |
1166 | 1173 | | |
1167 | | - | |
| 1174 | + | |
| 1175 | + | |
1168 | 1176 | | |
1169 | 1177 | | |
1170 | 1178 | | |
1171 | 1179 | | |
1172 | 1180 | | |
1173 | 1181 | | |
| 1182 | + | |
| 1183 | + | |
| 1184 | + | |
| 1185 | + | |
| 1186 | + | |
| 1187 | + | |
| 1188 | + | |
| 1189 | + | |
| 1190 | + | |
| 1191 | + | |
| 1192 | + | |
| 1193 | + | |
| 1194 | + | |
| 1195 | + | |
| 1196 | + | |
1174 | 1197 | | |
1175 | 1198 | | |
1176 | 1199 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
45 | | - | |
| 44 | + | |
| 45 | + | |
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| |||
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
137 | 149 | | |
138 | 150 | | |
139 | 151 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
26 | | - | |
| 26 | + | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
130 | 130 | | |
131 | 131 | | |
132 | 132 | | |
133 | | - | |
| 133 | + | |
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
| |||
154 | 154 | | |
155 | 155 | | |
156 | 156 | | |
157 | | - | |
| 157 | + | |
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
| 47 | + | |
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| |||
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
59 | | - | |
| 59 | + | |
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
67 | | - | |
| 67 | + | |
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
| |||
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
81 | | - | |
| 81 | + | |
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| |||
0 commit comments