You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 69147ab
Browse filesBrowse the repository at this point in the historyBrowse files
fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table; the lint refuses it (#24344)
Fixes#24316
Clause-②: no (narrowing) (owes one `CONTRACT_REVIEW_TIER` review before
the queue)
⚠️ **Security card.** This body keeps to the card's disclosure line:
category and position only. It carries no request, field path or value.
Tests use synthetic placeholders only.
## What changes
- **`@objectstack/objectql` — the shared one-hop loader
(`resolveOneHopRelated`).** A hop whose target is a stored-metadata-body
table (`isStoredMetadataBodyObject`, reused from
`@objectstack/spec/kernel`) is no longer read, under either authority.
The binding is `unreadable`, the shape an unreadable related record
already gets. A formula field reads `null` with its reason. A validation
rule has no verdict, so the write is refused. Neither answer says
anything about the row. One guard covers every exit that reaches the
loader: a read of the carrying object (REST, and a hook body's `ctx.api`
read), `findOne`, a write's response, `serveFormulaField` (the seam
`ctx.title()` reads through), and the validation-rule path, which reads
its hop as SYSTEM.
- **`@objectstack/lint` — the field-formula pass and hydrated validation
conditions.** A one-hop read through a reference into a family table is
an error, with a prescription (read stored metadata through the metadata
API, where the body is projected). `os validate`, `os build` and the
object save door run the same pass, so the author learns it at save time
instead of from a `null`.
- **The family's enumeration pin**
(`stored-metadata-body-family.pin.test.ts`) gains three rows: the
formula hop's exits, the validation-rule hop, and the author-time
refusal. Each row names its owning pin. 23 → 26 rows.
- Not done: no second projection, no `packages/spec` edit, no new error
code, and no change to the doors' projection or the reader-context seam.
## Reach, measured before the fix (live backend, showcase app,
`--fresh`)
The setup used a synthetic placeholder credential in a stored datasource
body, and a carrier object with a reference into the family plus
formulas through it.
| Reader | Family's own door | Through the hop (before) | Through the
hop (after) |
|---|---|---|---|
| administrator, session | redacted body, keyed hash | **yes**, 1/1
rows: placeholder and unkeyed hash served | no (engine pin) |
| administrator, API key | redacted, keyed | **yes**, 1/1 | no (engine
pin) |
| member granted read on the family table, session | redacted, keyed |
**yes**, 1/1 | no (engine pin) |
| same member, API key | redacted, keyed | **yes**, 1/1 | no (engine
pin) |
| member, no grant on the family table (default), session and API key |
refused (`PERMISSION_DENIED`) | no, 0/1 (formulas `null`) | no |
- **Validation-rule hop (SYSTEM authority), before:** an administrator
received the rule's own verdict over the unprojected body, which is a
one-bit oracle per write. A member with no grant got the unevaluable
refusal. After: no verdict for any caller (engine pin).
- **Master-detail `parent` root:** measured with no verdict served. The
write was refused as unbound, so it is out of this change.
| Producer door | Before | After |
|---|---|---|
| metadata save door (object type) | **accepted** (1/1 objects with
formula hops; 1/1 with a rule hop) | **refused** at author-time
validation, 2/2 objects (each hop named) |
| `os validate` / `os build` (`validateStackExpressions`) | accepted
(the lint pin goes red when its refusal is ablated) | refused (lint pin)
|
| package install | NOT MEASURED: no install of a package carrying such
an object was driven | — |
## Verification (head `7946ad1a2`, after merging `origin/main`)
- `pnpm --filter @objectstack/objectql test`: 440 files / 8290 tests
passed.
- `pnpm --filter @objectstack/lint test`: 145 / 6864 passed.
- `pnpm --filter @objectstack/metadata-protocol test`: 244 passed, 3
skipped / 20641 passed, 19 skipped.
- `typecheck` for objectql, lint and metadata-protocol: exit 0 each,
re-run after the merge and a rebuild of their dependency closure.
- After the merge, the targeted suites were re-run: 35, 443 and 9 tests
passed.
- Derived gates (`dispatch-gates.mjs --commands`, no paths): 71
commands, all exit 0 on `7946ad1a2`. `--ran` reconciliation: 71 derived,
71 run, 0 NOT-MEASURED (a derived zero).
- On the first run, two gates refused because the clone was shallow.
After deepening, both passed (exit 0).
- **Ablation 1 (engine guard disabled):** ran with
`scripts/ablation-replace.mjs`, which confirmed the mutation landed. The
engine pin went 10/10 red. The tool restored the file to the HEAD blob,
with `git diff HEAD` empty.
- **Ablation 2 (lint refusal disabled):** the lint pin went 5 red and 1
green. The green case is the control: a hop into an ordinary object and
a bare id read. The file was restored to the HEAD blob.
- **Pin sweep:** no other test declares a reference into the family. The
family pin's row count is the only pin that changed meaning, and it is
load-bearing (23 → 26).
- The two existing lint pins that moved are adjusted, not weakened:
- the #5017 receiver meta-test excuses the new Map/Set locals;
- the master-detail carrier refusal still comes from its own pass,
because the new index is read lazily.
## Acceptance notes
- The runtime answer reuses the `unreadable` binding, so a formula's
fault text reads "that read failed". The authored refusal with its
prescription is the lint's, at save.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DAEUSSMYBHAthcxTvba4yT)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table, and the lint refuses that read where it is written
7
+
8
+
Clause-②: no (narrowing)
9
+
10
+
<!-- adr-0087: not-required (no-migration-prescription) No stored metadata moves and nothing is converted: what narrows is a served value (a formula field or a validation rule that reads through a reference into a stored-metadata-body table) and an author-time verdict on such an expression; no spec key, export, option, object definition or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); nothing exported is removed (not runtime-export-retired) and no type changes (not type-surface-only or runtime-interface-only). -->
11
+
12
+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13
+
14
+
The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them. The one-hop read a formula field and a validation rule share (`resolveOneHopRelated`) was an ordinary engine read, which the family's projection does not reach.
15
+
16
+
-**`@objectstack/objectql`.** A hop whose target is a family table is not read, whichever authority the hop is read under. A formula field reading through it is `null` with its reason, on every read, write response and `serveFormulaField` call (the seam a hook body's `ctx.title()` reads through). A validation rule reading through it has no verdict, so the write it judges is refused. The answer is the one an unreadable related record gets, so it says nothing about the row. A hop into any other object is unchanged.
17
+
-**`@objectstack/lint`.** A formula field's `expression`, or a validation rule's hydrated `condition`, that reads through a reference into a family table is an error, with the prescription. `os validate`, `os build` and the object save door run the same pass, so the author learns it at save rather than from a `null`.
18
+
19
+
What to do. Remove the read through the reference. Stored metadata is read through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected. Reading the reference field itself (the stored id) is unchanged.
0 commit comments