Skip to content

fix(objectql): materialise the master-detail header a parent-scoped predicate reads (#6457) - #6788

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-6457-parent-header-materialize
Aug 8, 2026
Merged

fix(objectql): materialise the master-detail header a parent-scoped predicate reads (#6457)#6788
os-zhuang merged 3 commits into
mainfrom
claude/issue-6457-parent-header-materialize

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6457

readonlyWhen: parent.status == 'paid' / requiredWhen: parent.status == 'sent'
are documented server guarantees — #4889 and #4977 bound the parent root at
the write path so they are enforced by the engine and not only by the inline
grid. What was still storage-dependent is what that bound header contains.
The engine resolved it with a plain driver read and passed the row through as-is:

const row = await this.findOne(rel.master, { where: { id: parentId }, context: { isSystem: true } });
return row ?? null;

So a driver that returns only the columns it stored hands the predicate a header
missing the very key it reads. CEL is strict about missing keys, and the fault is
No such key: statusnot Unknown variable: parent, because parent IS
bound. That misses #4889's fail-closed carve-out and takes the ordinary fail-OPEN
exit: the readonlyWhen lock is let through and the field the author declared
frozen is written; the requiredWhen mirror is skipped and the record is accepted
with the field empty. Same declared ≠ enforced (PD #10), opposite directions.

This is #4953's trap on a different root. The 2026-08-06 ruling made record /
previous total at every server seam and deliberately left parent out, because
an ABSENT parent is #4889's fail-closed signal — and because the header is a row
of a DIFFERENT object whose declared fields the strip does not have.

Premises verified against origin/main before writing code

The PM ruling asked for two premises to be checked first; both hold at
e1e7629 (engine.ts after #6697's XL rewrite merged 15:21Z — every anchor below
is re-derived, not carried from the 08-07 issue body).

premise verdict evidence
(a) the MASTER schema is in hand at both call sites HOLDS — and better than "at the call sites" Both resolvers already hold rel.master (the master's object name, from resolveMasterDetailRelation), and ObjectQL holds this._registry. referenceExists twelve lines up in the same class does exactly this lookup. So the schema is reachable INSIDE the helpers — the ruling's chosen shape — with no plumbing and no signature change anywhere.
(b) materializeDeclaredFields reuses cleanly over the header row HOLDS materializeDeclaredFields(record, fields) (packages/objectql/src/declared-fields.ts, #4649 family) is generic over the record and takes the field table as a parameter — nothing in it is record-root-specific. Its one stated precondition, "only call this when the record's persisted state is IN HAND", is satisfied by construction here: the row was just read from the driver.

Neither premise is falsified, so no fork to report — and no master field table is
threaded through the strip functions.

What changed

ObjectQL.resolveMasterDetailParent and resolveMasterDetailParents now make
every header they resolve TOTAL over the MASTER object's declared fields, via a
new private materializeParentHeader / masterDeclaredFields pair.

  • One change, both consumers. The two resolvers are the single source of the
    parent binding for readonlyWhen (Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889), requiredWhen (Parent-scoped requiredWhen is unenforced server-side — the same gap #4889 closed for readonlyWhen, one slot over #4977) and
    previousParent (the ADR-0113 pre-check), across the single-id, bulk and
    insert paths. Fixing it here means one write can never judge its lock and its
    requirement against differently-shaped headers.
  • No signature changes. stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti
    and evaluateValidationRules are untouched — they receive a header that is
    already total. Their own contract (hand them a sparse header and they still
    fail open) is unchanged and still pinned.
  • No extra query. The declared-field table is a registry lookup, read ONCE
    per batch on the bulk path. Pinned by a read-counting test.
  • Copied before materialising, like every other caller: the driver may hand
    back the stored row by reference, and a leaked status: null would be visible
    to every later reader. Pinned by a test that asserts the stored header is
    byte-identical after the write.
  • A master the registry cannot resolve yields no field table and the header
    passes through unchanged — the honest answer, since without the declared shape
    we cannot tell an absent field from a fabrication.

Per-path declarations

path resolver before now
single-id updatereadonlyWhen strip resolveMasterDetailParent header raw header total over master's declared fields
single-id updaterequiredWhen + ADR-0113 previousParent resolveMasterDetailParent header raw total (same resolution, both slots)
bulk update (predicate) — both slots resolveMasterDetailParents headers raw total, one field-table lookup per batch
insert (incl. multi-row) — requiredWhen resolveMasterDetailParents (data: null) headers raw total, one batched header read unchanged
header UNRESOLVABLE (any path) either nullparent unbound nullparent unbound — unchanged

The verdict table — exactly one row moves

header state fault readonlyWhen before → now requiredWhen before → now
carries the key none evaluates → unchanged evaluates → unchanged
resolved, key absent No such key: <key> (parent IS bound) fail-OPEN, lock let through → evaluates, lock enforced fail-OPEN, requirement skipped → evaluates, requirement enforced
unresolvable (null) Unknown variable: parent LOCKED (#4889) → LOCKED, unchanged fail-OPEN (#4977) → fail-OPEN, unchanged

The bottom row is the hard constraint (#4889) and it is preserved by
construction
, not by a guard: absence is decided before materializeParentHeader
is ever called, so materialisation only ever applies to a row that exists. The
two paths are pinned by fault channel, not by the write's outcome — rows 2
and 3 can both end in "the field was not written", and only the warning says
whether that was an evaluated verdict or a refusal to guess:

  • row 2 asserts the reads 'parent' / LOCKED diagnostic is absent and the
    failed to evaluate — change allowed through exit is absent;
  • row 3 asserts the reads 'parent' + LOCKED diagnostic is present.

Tests

Extended the existing parent-binding surfaces rather than adding a lone file:

PR #6454's middle-row pin — re-annotated, deliberately NOT flipped (PD #13)

rule-validator.test.ts"does not disturb the #4889 parent binding" asserts
that stripReadonlyWhenFields, handed parent: { id: 'inv1' }, fails OPEN. That
assertion is kept verbatim, and the test renamed
(…parent stays unmaterialised…this function still materialises no header)
with the reason recorded inline.

Flipping it would have been wrong. The ruling puts the materialisation inside the
engine's resolvers, using the MASTER's field table — the one thing that pure
function does not and cannot have. So the strip's contract genuinely did not
move; a caller who hands it a sparse header still gets fail-open. What moved is
that the ENGINE no longer hands it one, and that verdict is pinned where the
change lives (the two engine suites above). The re-annotation points there
explicitly so the pair reads as one record: this one says the strip did not move,
that one says the write path did.

Reverse verification

Prediction written before mutating (removing both materialisation calls,
tests untouched): 11 RED — 5 in the readonlyWhen suite, 6 in the requiredWhen
suite, 0 in rule-validator.test.ts. Predicted vs actual at signature level is
posted as a comment below.

Scope

packages/objectql/src/engine.ts (the resolveMasterDetailParent(s) region and
their immediate seam only), the two engine test files, the rule-validator
re-annotation, and one changeset (@objectstack/objectql patch — behaviour moves
in both directions). No spec changes; no strip/validator signature changes;
nothing in the regions #5543 and #5929 are flying in. origin/main merged
immediately before opening.


Generated by Claude Code

claude added 2 commits August 8, 2026 17:35
…ate reads (#6457)

`resolveMasterDetailParent(s)` handed the driver-read header row through as-is,
so a `parent.<field>` predicate faulted with `No such key` on any driver that
did not echo back the column it reads. That fault is NOT `Unknown variable:
parent` — `parent` IS bound — so it missed #4889's fail-closed carve-out and
took the ordinary fail-OPEN exit: a `readonlyWhen` lock was let through and a
`requiredWhen` requirement was not enforced.

The header is now made TOTAL over the MASTER object's declared fields inside
the two resolvers, which are the only place holding both the master's schema
and the just-read row. One change serves both consumers; no strip or validator
signature moves; the same `materializeDeclaredFields` helper as every other
server seam (#1871/#4649/#4953); no extra query.

The fail-closed line is preserved exactly: materialisation only ever applies to
a header row that EXISTS, so an unresolvable header still leaves `parent`
unbound, still faults as `Unknown variable: parent`, and is still read as
LOCKED. Both paths pinned, and told apart by fault channel rather than by the
write's outcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNUt6cLqqcaLVbiDbin27R
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 6:06pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Reverse verification — predicted vs actual

Prediction written before the mutation (recorded in the working notes, reproduced verbatim below), then the mutation applied in a separate throwaway worktree so the branch tree was never touched: both materialisation calls removed (resolveMasterDetailParent returns the raw row; resolveMasterDetailParents stores the raw row in byId), tests and fixtures untouched.

Predicted: 11 RED — 5 readonlyWhen, 6 requiredWhen, 0 rule-validator.
Actual: Tests 11 failed | 184 passed (195)Test Files 2 failed | 1 passed (3).

Every test identity and every failure signature matched the prediction. No unpredicted red, no predicted red that stayed green.

engine-readonly-when-parent.test.ts — predicted 5, actual 5

test predicted signature actual signature
ROW 2 — THE FIX: a header missing the key now LOCKS instead of failing open toMatchObjectlocked_until_status: 'forged' vs expected 'kept' expected { id: 'line_sparse', …(7) } to match object { locked_until_status: 'kept' }
ROW 2: the strip is reported to the caller as readonly_when (#3407) toEqual — received [] expected [] to deeply equal [ { …(3) } ]
a sparse header that answers FALSE allows the change — by a VERDICT, not by a fault expect(true).toBe(false) — the fail-open warning reappears expected true to be false
CONSEQUENCE: has(parent.<declared>) is uniformly TRUE toMatchObjecthas_guard: 'forged' vs 'kept' expected { id: 'line_sparse', …(7) } to match object { has_guard: 'kept' }
BULK: the batch path materialises its headers too toMatchObjectlocked_until_status written expected { id: 'line_sparse', …(7) } to match object { locked_until_status: 'kept' }

Predicted GREEN and green: ROW 1, ROW 3 (unresolvable ⇒ LOCKED), BOUNDARY (undeclared key ⇒ fail-open either way), BULK under a header that carries the key, BULK unresolvable ⇒ LOCKED, header non-mutation, read-count.

engine-required-when-parent.test.ts — predicted 6, actual 6

test predicted signature actual signature
ROW 2 — THE FIX: an INSERT under a sparse header is now REJECTED expect(threw).toBe(true) receives false expected false to be true
ROW 2: an UPDATE that nulls the field under a sparse header is REJECTED same expected false to be true
ROW 2: the refusal is an EVALUATED verdict — the unbound-root diagnostic is absent expect(true).toBe(false) on requiredWhen for 'reason' failed to evaluate expected true to be false
judges a REPOINT onto a sparse header against the header it lands on expect(false).toBe(true) (repoint accepted) expected false to be true
BULK: the batch path materialises its headers too, per matched row expect(false).toBe(true) (batch accepted) expected false to be true
BULK INSERT: one batched header read still materialises every header expect(false).toBe(true) (insert accepted) expected false to be true

Predicted GREEN and green: ROW 1, ROW 2-accepted-when-supplied, ROW 3 (fail-OPEN + names the unbound root), ADR-0113 legacy row rests, header non-mutation, and every pre-existing #4977 test.

validation/rule-validator.test.ts — predicted 0, actual 0

The file passed in full under the mutation. That is the measured proof of the claim in the PR body: the strip's own contract did not move, which is why PR #6454's middle-row pin was re-annotated rather than flipped. A flip there would have been a false record — it would have asserted a behaviour change in a function this PR does not change.

Note the direction of the evidence for the hard constraint (#4889): the unresolvable-header rows are green on both sides of the mutation. They are not proven by this mutation and are not meant to be — they are pinned positively by their own tests (ROW 3, BULK: an unresolvable header still LOCKS the batch), asserted on the reads 'parent' + LOCKED fault channel rather than on the write outcome, precisely because rows 2 and 3 can share an outcome and differ only in diagnosis.

Gate run

Enumerated fresh from .github/workflows/lint.yml and run one by one against the final merged tree, after a full turbo run build --filter='!@objectstack/docs' --concurrency=2:

59/59 PASS, 0 FAILpnpm lint; all 41 check:* targets (including check:type-check-debt, check:type-check-coverage, check:engine-double-contract, check:driver-conformance, check:driver-memory-census, check:stall-guard, check:query-options-erasure); all 13 @objectstack/spec gates plus tsc --noEmit; @objectstack/lint check:doc-formula-expressions; downstream-contract and examples typechecks; and turbo run typecheck across packages/*, packages/*/*, apps/*.

Package suite: @objectstack/objectql149 files / 2598 tests, all passing, re-run after the origin/main merge (which brought in #6766's CEL front-end convergence, directly upstream of every predicate in this change).

No new fake engine was introduced, so assertEngineDeleteDispatch does not apply here — both suites drive the real ObjectQL through a fake driver, and check:engine-double-contract passes.


Generated by Claude Code

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parent 表头绑定也是稀疏的 —— #4953 修完 record 之后,同一个作者陷阱在 parent 根上原样留着

2 participants