Skip to content

fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) - #20028

Merged
os-litant merged 5 commits into
mainfrom
claude/issue-19727-ui-predicate-fault-submit-refuses
Sep 25, 2026
Merged

os-litant merged 5 commits into
mainfrom
claude/issue-19727-ui-predicate-fault-submit-refuses

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19727
Clause-②: no (narrowing)

What this does

ADR-0137 D2 (Accepted), verbatim: "At submit time, a field-rule predicate that cannot be evaluated refuses the write and names the field and the rule. Nothing is persisted." Triage's execution reading on the card (comment 5788580082) puts the server-side requiredWhen / readonlyWhen under D2, and this PR is the server half. Its scope is the two arms ADR-0137's own Context measured as fail-open:

  • requiredWhen (rule-validator.ts:3026-3044 at base 61609edf81): the arm logged requiredWhen for 'X' failed to evaluate — skipped and ran continue, so the record saved with the field empty. It now refuses. Every fault takes this arm, including an unbound parent (a master-detail header that could not be resolved for the write). The refusal goes into the call's ValidationError, which is thrown before any driver call.
  • readonlyWhen (rule-validator.ts:1350-1351 at base, inside isReadonlyWhenLocked): the non-root arm logged change allowed through and returned false, so the field was written. It now throws a ValidationError from the strip, before anything is written. The unbound-root carve-out is unchanged and still LOCKS.

Both arms go through the refusal builder that checkPredicate has used since the #4649 work. unevaluableRuleError gained a subject parameter, so the envelope stays the same: VALIDATION_FAILED (served as 400), one entry with field set to the field, code: 'rule_violation', and constraint: { rule: 'requiredWhen' | 'readonlyWhen', reason: 'unevaluable', fault, missingKey? , hint? }. The message reads Field 'X' requiredWhen could not be evaluated (...) — write rejected. The operator also gets a warn line saying the write was rejected.

Details:

  • Bulk readonlyWhen checks every matched row. Before, the check stopped at the first row that locked (some). Now a row that faults refuses the write no matter where it sits among the matched rows, so the verdict no longer depends on the order the driver returns rows in. The refusal names the row ((record ID)), the same way the bulk validation refusal does. Warnings are said once per key.
  • The refusal names a lookup read correctly. A predicate that reads through a lookup (record.account.tier) faults as No such key: tier, because field-level predicates are never hydrated. The generic sentence would tell the author to declare tier on the wrong object. The refusal instead says the column is read through account, a reference to fp_account, and points the author at a validations[] script rule. An unbound parent also gets its own sentence.
  • @objectstack/lint build-time messages. Three author-facing strings said the server skips a faulting requiredWhen, and this change makes them false: the unbound-root slot consequence, the parent-without-a-master consequence, and the null-guard outcome, which switches from 'fail-open' to 'fail-closed'. All three now say the write is refused. The doc-authoring prose-id baseline shrank by the two tracker ids those strings dropped. That is the gate's own prescribed remedy.

Not changed here, on purpose:

  • option-level visibleWhen (evaluateOptionVisibility), which D2 does not name and which stays fail-open;
  • the render side (ADR-0137 D3);
  • the readonlyWhen unbound-root carve-out, which LOCKS;
  • a predicate that evaluates, which is judged exactly as before in both directions;
  • the ADR-0113 legacy-row rule for an evaluated requiredWhen.

packages/spec is untouched.

Census: stored predicates that could start refusing

I checked every requiredWhen / readonlyWhen under packages/**, examples/** and apps/**, excluding tests, at 7a6e91f5fd. I evaluated each predicate with the real ExpressionEngine over normal and null bindings.

where predicate faults on a write it judges?
examples/app-showcase invoice issued_on requiredWhen: record.status in ['sent', 'paid'] no (a null status evaluates to false)
invoice tax_rate readonlyWhen: record.status == 'paid' no
invoice paid_on requiredWhen: record.status == 'paid' no
invoice_line description requiredWhen: record.quantity >= 100 faults only on a null quantity. quantity is required with defaultValue: 1. Measured on the engine: an insert without it gets the default and is accepted, and an update that nulls it is refused by required first. So no normal write reaches the fault
invoice_line product / quantity / unit_price readonlyWhen: parent.status == 'paid' only when unbound (unresolvable header), which takes the unchanged LOCKED arm
plugin-security sys_permission_set.name readonlyWhen: record.id != null && record.id != '' no

Result: none of the in-repo predicates start refusing. driver-sql's builtin-column-collision.ts names the keys as a classification map and holds no predicate. How many stored predicates in deployments fault is unmeasured. ADR-0137's Consequences says the loud state is what reveals them, and the changeset says so.

Pins, and the ablation that turns each red

The new file is packages/objectql/src/engine-field-predicate-fault.test.ts. It uses the real engine and an in-memory driver, and reads "nothing persisted" off the store. It has 13 cases.

pin ablation (via scripts/ablation-replace.mjs, WRAP mode) red
(a) requiredWhen fault refuses insert, update, supplied-field, lookup and bulk A1: fault arm back to continue 5 of 5 (a) cases
(b) readonlyWhen non-root fault refuses single-id and bulk A2: return false before the throw 3 of 3 (b) cases
(b) bulk refusal holds whatever the row order A3: stop at the first locking row the ok-row-first case only
(a) lookup refusal names the reference A4: traversal sentence off the lookup case only
(c) CONTROL requiredWhen FALSE accepted A5a: FALSE read as TRUE that case only
(c) CONTROL readonlyWhen FALSE written A5b: every lock reads LOCKED that case only
(c) CONTROL requiredWhen TRUE refused as required A5c: TRUE routed to the fault builder that case only
(c) CONTROL readonlyWhen TRUE dropped, no refusal A5d: every lock reads UNLOCKED that case only
(d) CONTROL option visibleWhen stays fail-open A6: the option fault arm pushes an error that case only

For every leg, the anchor hit x1 and went to x0, the blob changed, and the restore was proven: blob 5bfc176263 equals HEAD and git diff HEAD is empty. The script also carried an EXIT/INT/TERM trap that restores by absolute path. No build was needed: the subject resolves through relative source imports (./engine.js → ./validation/rule-validator.js), not through dist/.

Existing pins that locked in the retired fail-open branch were flipped. Each still pins its original subject:

  • rule-validator.test.ts (7 cases);
  • rule-fail-closed.test.ts (the requiredWhen "neighbour");
  • engine-required-when-parent.test.ts (orphan header, 3 cases);
  • engine-readonly-when-parent.test.ts (undeclared header key; the three not change allowed through absence checks were retargeted to the refusal channel so they are not vacuous);
  • engine-readonly-when-interdependent-locks.test.ts (a faulting FK lock);
  • engine-reference-tenant-scope.test.ts (4 cases). The invariant these pin, that a locked, an open and a missing header give one answer, holds. The answer is now note: rule_violation for all of them.

One fixture was re-spelled: rule-validator.test.ts "accepts the write once the field is supplied" now supplies quantity. Its unrelated record.quantity >= 100 rule had been faulting silently.

Test readings

  • @objectstack/objectql at 7a6e91f5fd: 313 files, 5256 tests passed. typecheck exits 0, and check:test-typecheck holds its ledger.
  • @objectstack/lint at 7a6e91f5fd: 108 files, 4138 tests passed. typecheck exits 0.
  • @objectstack/runtime at 7a6e91f5fd: 276 files, 3893 passed, 1 skipped.
  • @objectstack/plugin-security at b25e969e1d: 128 files, 2501 passed. It reads @objectstack/objectql through a dist/ built from the same rule-validator.ts.
  • @objectstack/rest at b25e969e1d: 194 files, 3265 passed, 1 skipped. Same dist/ as above.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 7a6e91f5fd derived 72 commands. I ran all 72: 71 exit 0. pnpm check:dual-build-cjs-loads exits 3 (PREREQUISITE NOT MET, needs a full pnpm build), so it is NOT MEASURED locally. --ran reconciliation: 72 derived, 71 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN.

  • check:adr-0087-registration is green. The changeset reads as BREAKING+bang+clause-②-narrowing, and its disposition is not-required (no-migration-prescription): nothing authored moves and no stored shape is refused.
  • check:objectql-double-limit went red once, on the new test's find double, and is green now that the double honours limit.
  • check:doc-authoring went red once, on a stale over-pin, and is green after the prescribed baseline shrink.

Narrowed lint, instead of the CI-owned pnpm lint: I ran eslint --no-inline-config --format json on the 13 touched .ts files. Result: 13 files, 0 errors, 0 warnings, none ignored. All 13 fall under the **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} config object (eslint.config.mjs:971). That config never enables type-aware linting (no parserOptions.project, per its own note at eslint.config.mjs:327), so this diff cannot move the verdict on an untouched file.

Acceptance notes

  • D2 is applied literally: the submit is refused whatever the write does to the field. A requiredWhen whose predicate faults refuses even a write that supplies the field, and a readonlyWhen fault refuses even an echoed, unchanged value. There is a measured alternative. CEL absorbs an error in && (record.statsu == 'closed' && record.reason == null evaluates to false when reason is set). So the same rule written as a validations[] script refuses only when the field is empty. The report puts this to the seat as an open question and recommends staying literal.
  • Error precedence moved for an unresolvable header. evaluateValidationRules runs before assertReferencesResolve. A detail whose header FK does not resolve, and that carries a parent-scoped requiredWhen, now meets the requiredWhen refusal ("no header could be resolved") before header: reference_not_found. Both are loud, and the tenant-scope no-leak invariant holds.
  • A field-level predicate that reads through a lookup always refuses. The field level is never hydrated; only validation conditions are. Neither the schema nor lint refuses such a predicate at authoring (validate-expressions.ts passes traversalHydration for validation rules only). The census found none in this repository.
  • After this change, NullGuardOutcome's 'fail-open' member has no caller, and its clause still describes the old requiredWhen runtime. Its doc comment says so. It is not removed here.
  • The diff goes beyond the claim's declared file surface (rule-validator.ts, its tests, .changeset/). It adds packages/lint/**, packages/objectql/src/{cel-fault,master-detail}.ts (docblocks this change made false), five more packages/objectql/src/*.test.ts pin files, and scripts/doc-authoring-prose-id.baseline.json.

Generated by Claude Code

…ADR-0137 D2) [wip]

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
…oduces (ADR-0137 D2) [wip]

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
…ns it retires [wip]

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
… the prose-id baseline the lint rewrite burned down [wip]

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ai/skills-reference.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/api/error-catalog.mdx (via rule_violation (literal, a string literal in a comment on a changed line))
  • content/docs/automation/hook-bodies.mdx (via readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked))
  • content/docs/data-modeling/field-types.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/data-modeling/fields.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/data-modeling/formulas.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/data-modeling/validation-rules.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/deployment/cli.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/deployment/validating-metadata.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/kernel/contracts/data-engine.mdx (via readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked))
  • content/docs/protocol/kernel/error-handling.mdx (via rule_violation (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectql/security.mdx (via readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked))
  • content/docs/protocol/objectui/concept.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/protocol/objectui/layout-dsl.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))

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

  • content/docs/releases/implementation-status.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/releases/v15.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/releases/v16.mdx (via evaluateValidationRules (symbol, a top-level function), requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), validateStackExpressions (symbol, a top-level function), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/releases/v17/17-0.mdx (via requiredWhen (symbol, a field of const object FIELD_RULE_SLOT_CONSEQUENCE), requiredWhen (literal, a string literal in FieldRuleSlot; a string literal in evaluateValidationRules; a string literal in validateStackExpressions))
  • content/docs/releases/v17/17-1.mdx (via readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked))
  • content/docs/releases/v17/17-4.mdx (via readonlyWhen (literal, a string literal in FieldRuleSlot; a string literal in isReadonlyWhenLocked))

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
  • 2 changed file(s) yielded no anchor (packages/objectql/src/cel-fault.ts, packages/objectql/src/master-detail.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 21 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 44639665eea4adc6de1eaa963ccd0e5a3e875f98 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 282c99d4c674462d783bfca412d1d5ff302a9a21 — the merge of head 7a6e91f5fd35ef92a86bdca202b87cc485bd045a into base 44639665eea4adc6de1eaa963ccd0e5a3e875f98, 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 282c99d4c674462d783bfca412d1d5ff302a9a21 && git checkout 282c99d4c674462d783bfca412d1d5ff302a9a21
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 44639665eea4adc6de1eaa963ccd0e5a3e875f98 7a6e91f5fd35ef92a86bdca202b87cc485bd045a && git checkout -B drift-repro 44639665eea4adc6de1eaa963ccd0e5a3e875f98 && git merge --no-ff 7a6e91f5fd35ef92a86bdca202b87cc485bd045a

node scripts/docs-audit/affected-docs.mjs --json 44639665eea4adc6de1eaa963ccd0e5a3e875f98

⚠️ 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 44639665eea4adc6de1eaa963ccd0e5a3e875f98 → 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: 162/162 CONTRACT_REVIEW_TIER
Head-sha: 7a6e91f5fd35ef92a86bdca202b87cc485bd045a

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 162 transcript turns served at the tier the constant names. The reviewer's own Served-tier line named a model and was replaced by the transcript-measured line. The seat adopts the FAIL. Its one blocking item is the PR body's Clause-② line, a seat-owned write: the seat corrects it in this act and has the same reviewer re-check the same head. Adopted by the seat 2026-09-24T20:33Z. The record below is the reviewer's, unedited except the two header lines.

Inputs read: card #19727 body and all 7 comments (5777755280, 5788210552, 5788580082, 5789283997, 5808379899, 5819444783, os-dev-report 5821543547); docs/adr/0137-predicate-fault-semantics-are-contract.md at origin/main = 44639665eea4adc6de1eaa963ccd0e5a3e875f98; pulls/20028 body, 15 files, the diff (1851 lines), 1 bot comment (docs-drift), 0 review comments, 0 reviews; commits/7a6e91f5fd…/check-runs polled three times; AGENTS.md, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, .github/workflows/pr-automation.yml at origin/main; the dev's report.json (as a claim). Identity: git rev-parse origin/main:packages/objectql/src/validation/rule-validator.ts = 7fb0dd6f56 (the diff's base blob), head blob 5bfc176263; merge-base a0920b42dc; 4 wip commits + 1 merge of origin/main; git diff --name-only a0920b42dc origin/main touches none of the 15 files.

① Derived judgments

(a) Scope — implements D2 on exactly the two ruled arms.

  • requiredWhen arm: base rv-base.ts:3039-3041 (origin/main) logged failed to evaluate — skipped and continued (the PR body's cited 61609edf81:3026-3044 also holds, verified with git show). Head refs/review/pr-20028:packages/objectql/src/validation/rule-validator.ts:3121-3145: every !res.ok now errors.push(unevaluableFieldRuleError('requiredWhen', …)); errors is thrown at :3247 (throw new ValidationError(errors)). Unbound parent is included in the arm (no unknownVariableOf branch remains before the push) and gets its own sentence at :3520-3524.
  • readonlyWhen non-root arm: base rv-base.ts:1350-1351 warned change allowed through and return false. Head :1395-1398 throws ValidationError([unevaluableFieldRuleError('readonlyWhen', …)]). The unbound-root carve-out at head :1386-1394 is unchanged: warn treating the field as LOCKED and return true.
  • Option visibleWhen: awk extraction of evaluateOptionVisibility from both blobs, diff = empty, 64 lines each — byte-identical; continue; // fail-open still at its line 51. Render side: no objectui/renderer file in the 15; D3 untouched.
  • Nothing beyond: checkPredicate and unevaluableRuleError's envelope shape are unchanged apart from a defaulted subject parameter (:3437), so the Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649 refusal path produces byte-identical output for validation rules.

(b) Loud and complete; nothing persisted.

  • Envelope: code: 'rule_violation', constraint: { rule: 'requiredWhen'|'readonlyWhen', reason: 'unevaluable', fault, missingKey?, hint? }, message Field 'X' requiredWhen could not be evaluated (…) — write rejected. (:3447-3460, :3491-3541). ValidationError.code = VALIDATION_FAILED (asserted end to end in engine-field-predicate-fault.test.ts via validationFailureDetails).
  • Ordering at head engine.ts: insert — evaluateValidationRules :11985 precedes driver.bulkCreate/create :12056/:12059; single-id update — readonlyWhen strip :13380 and rules :13428 precede driver.update :13446; bulk — stripReadonlyWhenFieldsMulti :13600 and per-row rules :13703 precede updateMany; validate() :11161 runs the rules at :11276 and returns { valid:false, errors } on ValidationError. No catch swallows: the only catches near the strip sites are :13445 (driver error) and :13702 (re-wraps with (record id) and rethrows); settleMasterDetailLanding's two catches (:7187, :7239) wrap header lookups, not judgeFkLock (:7115); settleLockGroup/settleReadonlyWhenDrops (rv-head.ts:1077-1180) contain no try/catch.
  • Bulk strip (:1628-1672): views.forEach judges every row, accumulates locked, rethrows a fault with (record ${row.id}). Correct: with some the verdict depended on row order. No new leak: priorRows come from readPriorRows (engine.ts:12969-12982) via driver.find(object, ast, preOpts) where preOpts = this.buildDriverOptions(object, opCtx.context, …) — the caller's own matched rows — and the (record id) suffix is the pre-existing bulk validation shape at engine.ts:13705.
  • Log line: settleOrRefuse (:1411-1420) warns each refusal once; the requiredWhen arm warns at :3141.

(c) Open question 1 — D2 read literally is what D2 says. ADR-0137 D2 at origin/main, verbatim: "At submit time, a field-rule predicate that cannot be evaluated refuses the write and names the field and the rule. Nothing is persisted." and "A rule that could not run has produced no verdict. Treating 'no verdict' as the author's verdict is the whole defect; refusing is the only answer that neither invents a verdict nor hides that one is missing." No clause conditions the refusal on the field being empty or the value changed; option B ("refuse only when the verdict matters") hides a missing verdict exactly where D2 forbids hiding it. The head's literal reading (:3134-3137; pinned by the "supplies the field" case) matches the text.

(d) Extra files — each forced by the change, none creep.

  • packages/lint/src/validate-expressions.ts :808-811, :1664, :1708 and validate-null-guards.ts :582-592: three author-facing strings said the server "logs and SKIPS"/"never enforced"; false after D2. Forced. packages/lint/src/validate-expressions.test.ts: pins of those strings. The readonlyWhen consequence (:801-806, "LOCKED … 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") is untouched and still true.
  • cel-fault.ts:14-19, master-detail.ts:31-37: docblocks stated fail-open; forced.
  • scripts/doc-authoring-prose-id.baseline.json: #4889 2→1, #4977 removed for validate-expressions.ts — the ids left the strings; the gate's prescribed shrink.
  • Flipped pins: rule-validator.test.ts (7), rule-fail-closed.test.ts (1), engine-required-when-parent.test.ts (3), engine-readonly-when-parent.test.ts (1 + 3 retargeted absence checks), engine-readonly-when-interdependent-locks.test.ts (1), engine-reference-tenant-scope.test.ts (4). No guarantee lost: the tenant no-leak invariant is still asserted as one identical envelope for locked/open/nowhere; account: reference_not_found remains pinned at tenant-scope :266; data: a lookup accepts an id that does not exist in the referenced object — including the RBAC permission-set link tables #4441's dangling-FK guard remains pinned in engine-lookup-referential-integrity.test.ts (git grep -l reference_not_found refs/review/pr-20028 -- '**/*.test.ts'). The re-spelled fixture (quantity: 1 supplied) keeps its subject: the row-scoped record.quantity ≥ 100 rule was faulting silently on null and would now refuse for an unrelated reason.
  • git diff --stat merge-base..head: 15 files, +979/−273 = 1252, matches the report.

(e) Semver and markers.

  • '@objectstack/objectql': minor + **BREAKING** banner + bang summary + Clause-②: no (narrowing) + adr-0087: not-required (no-migration-prescription): consistent with check-changeset-no-major.mjs:47-75 (launch window: breaking ships minor, carried by banner + disposition) and check-adr-0087-registration.mjs:632-640 (signals BREAKING, bang, clause-②-narrowing; :147-153 no-migration-prescription is refused only when the body carries a FROM→TO rewrite — it does not; both packages private=False so unpublished is correctly not claimed). '@objectstack/lint': patch is right under pr-automation.yml:753-760 ("a fix( that changes no public surface stays patch"): reworded messages, no widened surface.
  • Changeset sentences verified true: the two arms, envelope, "before anything is written", "an envelope with no evaluable source: blank, or ast-only" (packages/formula/src/cel-engine.ts:1773-1782 returns a parse fault for source.length === 0 or non-string), the three lint messages, the census.
  • ⛔ Marker inconsistency (blocking, see ③): PR body line 2 reads Clause-②: no; the changeset reads Clause-②: no (narrowing). AGENTS.md:1083-1084 says the changeset body "also carries the PR's Clause-② line" — the two carriers differ. Consequence measured in the gate: check-changeset-no-major.mjs:1455-1460 reads "ONE carrier: the fixed Clause-②: line in the PR body"; no with no arm routes to not-declared (exit 0, :1528-1536), so the level axis never checked that a moved package is graded minor+. Check Changeset concluded success without measuring the axis a declared narrowing is supposed to arm. The dev's own report names this as open question 3 and recommends the body edit.

(f) Census — verified myself. git grep -n -E '\b(requiredWhen|readonlyWhen)\s*:' refs/review/pr-20028 -- packages examples apps (excluding tests, docs and describing sources; JSON/YAML/content/skills extension found only prose) returns exactly the dev's set: examples/app-showcase/src/data/objects/invoice.object.ts :131 record.status in ['sent','paid'], :144 record.status == 'paid', :153 same, :266/:307/:313 parent.status == 'paid', :277 record.quantity ≥ 100; plugin-security/src/objects/sys-permission-set.object.ts:246 record.id != null && record.id != ''; driver-sql/src/builtin-column-collision.ts:119-120 is a classification map. quantity at :302-306 is required: true, defaultValue: 1, so the one faultable predicate needs a null quantity. Reading holds.

(g) Pins weight-bearing. Ablations not re-run (⛔). By assertion reading: (a) cases assert the throw AND the store unchanged, so restoring continue lands the row (A1 red ×5); (b) cases assert throw + row unchanged (A2 red ×3); with some the ok-first order locks on ok and never reads bad (A3 red on that case only, as claimed); the lookup case asserts the literal "through 'account', a reference to 'fp_account'" (A4); (c)/(d) controls assert the non-fault paths. The dev's ablation table is consistent with what the assertions can discriminate.

② Semver level

minor for @objectstack/objectql is correct: an accept-set narrowing at runtime is BREAKING, and during the launch window breaking ships minor carried by the **BREAKING** banner and the ADR-0087 disposition (check-changeset-no-major.mjs:47-75, pr-automation.yml:756-760). patch for @objectstack/lint is correct (message text only). The changeset's markers are internally consistent and gate-consistent. The PR-body declaration (Clause-②: no) is not consistent with the changeset's (no (narrowing)) — see ③.

③ Boundary flags

  • Blocking — marker inconsistency. PR body line 2 Clause-②: no vs changeset Clause-②: no (narrowing). The changeset is right; the PR body under-declares and, as measured above, disarmed the level axis of Check Changeset (verdict not-declared instead of discharged). Remedy is one PR-body edit: line 2 → Clause-②: no (narrowing). No code, changeset or pin needs to move. (Claim 5819444783 also says no, so the seat's claim line should be amended alongside.)
  • Non-blocking — surface beyond the claim: packages/lint/** (a second cross-lane package the claim did not declare), cel-fault.ts, master-detail.ts, five pin files, the baseline. Each forced by "fix what your change makes false"; the dev reported every one.
  • Non-blocking — bare-string blank residual: a stored bare '' shorthand is skipped before evaluation (rv-head.ts:3120 if (!pred) continue; and :950 !def?.readonlyWhen), so it stays a silent no-rule; the envelope form { source: '' } and ast-only DO refuse (cel-engine.ts:1773-1782). D2's "a blank predicate takes this path too" has this one residual, outside the card's two-arm scope; D1 (feat(spec)!: every engine-evaluated expression slot requires a non-blank source #18638) refuses blank at authoring.
  • Non-blocking — validate() (engine.ts:11161) runs the rules but never the readonlyWhen strip, so the preview surfaces the requiredWhen refusal but cannot preview a readonlyWhen fault refusal. Pre-existing shape.
  • Non-blocking — error precedence: evaluateValidationRules (:13428) precedes assertReferencesResolve (:13430); a detail with a parent-scoped requiredWhen under an unresolvable header now meets note: rule_violation before header: reference_not_found. Same envelope for locked/open/nowhere, so no leak.
  • Non-blocking — census residual: a legacy showcase_invoice_line row whose stored quantity is null (ADR-0113 lets required rest on it) would now refuse every update via description's record.quantity ≥ 100; the dev's "no normal write reaches the fault" holds for defaulted inserts and null-ing updates. This is the ADR's intended loud state; the changeset says so.
  • Non-blocking — unbound-root readonlyWhen still LOCKS (dev open question 2). Triage 5788580082 scoped the card to the non-root arm; recorded, not re-ruled here.
  • Non-blocking — NullGuardOutcome 'fail-open' member now has no caller (validate-null-guards.ts:585-592 says so).
  • Line budget: 1252 changed lines (under 5000). No governed-surface path among the 15 files.
  • CI at head, last poll: 26 success, 3 skipped, 3 in_progress (Test Core (1/6), Lint & Repo Gates, Type Check · workspace). Green so far: Build Core, Dogfood Regression Gate (rollup + 3 shards), Temporal Conformance (live PG + MySQL), Test Core 2/6–6/6, Governed Surface Queue Guard, Check Changeset, Check PR Size, three Type Check gates. mergeable: true, mergeable_state: blocked (draft, checks pending).
  • Not re-run locally (⛔): gate families, test suites, ablations. check:dual-build-cjs-loads NOT MEASURED by the dev (exit 3).

Implemented-by: claude/issue-19727-ui-predicate-fault-submit-refuses
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: FAIL — one blocking item: PR body line 2 Clause-②: no contradicts the changeset's Clause-②: no (narrowing) (AGENTS.md:1083-1084 requires the changeset to carry the PR's line), and the omitted arm routed Check Changeset's level axis to not-declared so the minor grade a declared narrowing owes was never measured. Remedy: edit PR body line 2 to Clause-②: no (narrowing) (and the claim line). Every runtime change, pin, census reading, changeset sentence and semver level otherwise passes against ADR-0137 D2 and the card's scope.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 196/196 CONTRACT_REVIEW_TIER
Head-sha: 7a6e91f5fd35ef92a86bdca202b87cc485bd045a

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 196 transcript turns served at the tier the constant names. Re-check of the unchanged head after the seat corrected PR body line 2 (the prior FAIL 5821798690's one blocking item); Check Changeset re-ran green with the level axis armed. Same reviewer, one transcript, turns counted across both passes. Adopted by the seat 2026-09-24T20:39Z. The record below is the reviewer's, unedited except the two header lines.

Re-check of the same head (unchanged: pulls/20028 head.sha = 7a6e91f5fd35ef92a86bdca202b87cc485bd045a, base 44639665eea4adc6de1eaa963ccd0e5a3e875f98, updated_at 2026-09-24T20:34:24Z, still draft). Inputs re-read: the PR body; check-run 107822209496 and its annotations; card note 5821816253 and the landing-stop note 5819399538 it cites; scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, scripts/check-closing-target-claim.mjs, .claude/skills/pm-dispatch/references/execution-duties.md at origin/main; commits/7a6e91f5fd…/check-runs polled again. All code judgments below are carried from the prior record on the identical blobs (rule-validator.ts base 7fb0dd6f56 → head 5bfc176263; merge-base a0920b42dc; 15 files, +979/−273 = 1252). Not readable: the job log for actions/jobs/107822209496/logs (the download redirect is refused by the agent proxy, CONNECT 403), so the level-axis verdict is derived from the script, not read from the log.

① Derived judgments

(a) Scope — D2 on exactly the two ruled arms. requiredWhen: base rule-validator.ts:3039-3041 (origin/main) logged failed to evaluate — skipped and continued; head :3121-3145 pushes unevaluableFieldRuleError('requiredWhen', …) on every !res.ok (unbound parent included, own sentence at :3520-3524), thrown at :3247. readonlyWhen non-root: base :1350-1351 warned change allowed through and return false; head :1395-1398 throws ValidationError([unevaluableFieldRuleError('readonlyWhen', …)]). Unbound-root carve-out :1386-1394 unchanged (return true, LOCKED). evaluateOptionVisibility byte-identical base vs head (64 lines, diff empty; continue; // fail-open intact). No renderer file in the diff; D3 untouched. checkPredicate/unevaluableRuleError envelope unchanged apart from a defaulted subject (:3437).

(b) Loud and complete; nothing persisted. Envelope code: 'rule_violation', constraint: { rule: slot, reason: 'unevaluable', fault, missingKey?, hint? }, message Field 'X' requiredWhen could not be evaluated (…) — write rejected. (:3447-3460, :3491-3541); ValidationError.code = VALIDATION_FAILED. Ordering at head engine.ts: insert rules :11985 before driver.bulkCreate/create :12056/:12059; single-id strip :13380 and rules :13428 before driver.update :13446; bulk strip :13600 and per-row rules :13703 before updateMany; validate() :11161 returns { valid:false, errors } on the rules' ValidationError (:11276). No swallowing catch (:13445 driver error; :13702 re-wraps with (record id) and rethrows; settleMasterDetailLanding's catches :7187/:7239 wrap header lookups, not judgeFkLock :7115; no try/catch in settleReadonlyWhenDrops/settleLockGroup :1077-1180). Bulk strip :1628-1672 judges every row via forEach, accumulates locked, names the faulting row; priorRows are read at the caller's context (readPriorRows :12969-12982, buildDriverOptions(object, opCtx.context, …)), and (record id) is the pre-existing bulk validation shape at :13705 — no new leak.

(c) Open question 1. ADR-0137 D2 verbatim: "At submit time, a field-rule predicate that cannot be evaluated refuses the write and names the field and the rule. Nothing is persisted." … "refusing is the only answer that neither invents a verdict nor hides that one is missing." No clause conditions the refusal on the field being empty or changed; the literal implementation (:3134-3137, pinned by the "supplies the field" case) is the ruled text. Seat note 5821816253 item 1 concurs.

(d) Extra files — each forced, none creep. packages/lint/src/validate-expressions.ts:808-811, :1664, :1708 and validate-null-guards.ts:582-592 (strings that said the server skips — false after D2) plus their pin test; cel-fault.ts:14-19, master-detail.ts:31-37 (docblocks stated fail-open); scripts/doc-authoring-prose-id.baseline.json (#4889 2→1, #4977 gone — the ids left the strings; gate-prescribed shrink); flipped pins in 6 test files. No guarantee lost: tenant no-leak invariant still asserted as one identical envelope for locked/open/nowhere; account: reference_not_found still pinned at tenant-scope :266; #4441's guard still pinned in engine-lookup-referential-integrity.test.ts; the lint readonlyWhen consequence (:801-806, LOCKED) untouched and still true. The re-spelled fixture (quantity: 1) keeps its subject.

(e) Semver and markers — now consistent.

  • PR body line 2 now reads exactly Clause-②: no (narrowing) (read from pulls/20028 body, updated_at 2026-09-24T20:34:24Z). The changeset reads Clause-②: no (narrowing). The two carriers match (AGENTS.md:1083-1084).
  • Check Changeset re-ran on the edited body: check-run 107822209496, head_sha = this head, completed/success, started 20:34:30Z, completed 20:35:25Z. Two annotations: a notice on .changeset/19727-field-rule-predicate-fault-refuses-submit.md re-validating the ADR-0087 exemption not-required (no-migration-prescription) with the changeset's own why, and an unrelated ubuntu-latest runner notice.
  • Level-axis verdict for no (narrowing), derived from check-changeset-no-major.mjs at origin/main: declarationFromPullRequest (:1493-1523) reads value no, arm narrowing; judgeLevel (:1646-1660) does not stand the axis down (arm is narrowing); moved packages/**/src/** = @objectstack/objectql (graded minor → raised) and @objectstack/lint (graded patch → offenders); refusable = offenders.length > 0 && raised.length === 0 = false; offenders.length = 1 → verdict discharged (exit 0), printing lint's patch as the named residual. That residual is correct: lint's change is message text only (pr-automation.yml:756-757, "a fix( that changes no public surface stays patch").
  • '@objectstack/objectql': minor + **BREAKING** banner + bang summary + no (narrowing) + not-required (no-migration-prescription): consistent with the launch-window convention (check-changeset-no-major.mjs:47-75) and the ADR-0087 gate's classification (check-adr-0087-registration.mjs:632-640; no FROM→TO rewrite in the body, both packages private=False). Every changeset sentence verified true (both arms, envelope, blank/ast-only envelope faults at cel-engine.ts:1773-1782, the three lint messages, the census).
  • Claim line stays Clause-②: no — consistent. execution-duties.md:65 (origin/main): 「Clause-②: yes | no 恰这两种拼写」 — the claim comment takes exactly those two spellings, answering the widening question, and no is the truthful answer. The arm grammar (clause2-line.mjs:91-97) belongs to the lines the two gates read: the PR body (check-changeset-no-major.mjs:1455-1460, "ONE carrier: the fixed Clause-②: line in the PR body") and the changeset (check-adr-0087-registration.mjs:639-640). No script at origin/main cross-compares the claim's line with the PR body's (check-closing-target-claim.mjs reads no Clause-② value; git grep "Clause-②" origin/main -- scripts .github shows only fixtures elsewhere). The seat's reading in 5821816253 item 3 holds.

(f) Census — verified. git grep at the PR ref returns exactly the dev's set: examples/app-showcase/src/data/objects/invoice.object.ts :131, :144, :153 (record.status predicates; a null status evaluates), :266/:307/:313 (parent.status == 'paid'), :277 (record.quantity ≥ 100, faultable only on a null quantity, which is required: true, defaultValue: 1 at :302-306); plugin-security/src/objects/sys-permission-set.object.ts:246; driver-sql/src/builtin-column-collision.ts:119-120 is a classification map. JSON/YAML/content/skills spellings: prose only.

(g) Pins weight-bearing. Ablations not re-run (⛔). The assertions discriminate what the dev's table claims: (a) cases assert throw + store unchanged (A1); (b) cases assert throw + row unchanged (A2); the ok-first order is the only case some would silence (A3); the lookup case asserts the literal "through 'account', a reference to 'fp_account'" (A4); the (c)/(d) controls assert the non-fault paths (A5a-d, A6).

② Semver level

minor for @objectstack/objectql is correct (accept-set narrowing at runtime = BREAKING; launch window ships minor, carried by the banner and the ADR-0087 disposition). patch for @objectstack/lint is correct. PR body, changeset and claim declarations are now mutually consistent under their respective grammars; Check Changeset on the edited body concluded success with the level axis armed (discharged).

③ Boundary flags

  • Prior blocking item CLEARED: PR body line 2 is Clause-②: no (narrowing); Check Changeset 107822209496 success on this head; level axis reaches discharged, not not-declared.
  • Non-blocking — CI state at the last read (check-runs, 41 runs): 35 success, 5 skipped, 1 in_progress. Required contexts: TypeScript Type Check success (20:31:50Z), Test Core success (rollup 20:36:29Z; shards 1/6–6/6 all success), Dogfood Regression Gate success (rollup + 3 shards), Build Core success, Temporal Conformance (live PG + MySQL) success, Governed Surface Queue Guard success, Lint & Repo Gates (107814460012) still in_progress — per AGENTS.md "in_progress is not a pass", so the PR is not yet green for arming. Check Changeset, Check PR Size, the claim/single-writer/part-of guards, Dogfood Verify CLI, Check Documentation Links, Flag docs affected all success. mergeable: true, mergeable_state: blocked (draft, one check pending).
  • Non-blocking — the Check Changeset job log was not readable through the proxy; the discharged reading is derived from the script's code, not from the printed output. The check-run's conclusion: success and its ADR-0087 notice were read directly.
  • Non-blocking — landing is stopped independently of this review: 5819399538 (issue [finding] the lint's filter walk (FILTER_KEYS = filter · filters · runtimeFilter · relatedListFilter) never reaches a page's interfaceConfig.filterBy — a bare date-range preset in that rule array parses green AND lints green, so nothing refuses it at publish #19791) records the auto-mode classifier denying the allow-listed ccr/ready_for_review call for a sibling PR; the seat's note 5821816253 says this PR's landing waits for the maintainer on the same denial.
  • Non-blocking — surface beyond the claim (packages/lint/** as a second cross-lane package, two docblocks, five pin files, the baseline): each forced; now recorded as the claim-surface amendment in 5821816253.
  • Non-blocking — bare-string blank residual: a stored bare '' shorthand is skipped before evaluation (rule-validator.ts:3120 if (!pred) continue;, :950 !def?.readonlyWhen); the envelope form { source: '' } and ast-only DO refuse. Outside the card's two-arm scope; D1 (feat(spec)!: every engine-evaluated expression slot requires a non-blank source #18638) refuses blank at authoring.
  • Non-blocking — validate() never runs the readonlyWhen strip, so it previews the requiredWhen refusal but not a readonlyWhen fault refusal (pre-existing shape).
  • Non-blocking — error precedence: rules (:13428) before assertReferencesResolve (:13430); a parent-scoped requiredWhen under an unresolvable header meets note: rule_violation before header: reference_not_found; identical envelope for locked/open/nowhere, no leak.
  • Non-blocking — census residual: a legacy showcase_invoice_line row with a stored null quantity would now refuse every update via description's predicate; the ADR's intended loud state, named in the changeset.
  • Non-blocking — unbound-root readonlyWhen still LOCKS; triage 5788580082 scoped the card to the non-root arm; 5821816253 item 2 records it for triage, not re-ruled here.
  • Non-blocking — NullGuardOutcome 'fail-open' member has no caller (validate-null-guards.ts:585-592 says so).
  • Line budget 1252 (under 5000); no governed-surface path among the 15 files; origin/main drift a0920b42dc..44639665ee touches none of them. Nothing written to GitHub or any repository; no suites or gate families run.

Implemented-by: claude/issue-19727-ui-predicate-fault-submit-refuses
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS — the single blocking item from the prior record is cleared on the unchanged head: PR body, changeset and claim now agree under their respective grammars, and Check Changeset re-ran green with the level axis armed (discharged). The head implements ADR-0137 D2 on the server-side requiredWhen arm and the non-root readonlyWhen arm only, refuses loudly with VALIDATION_FAILED naming field and rule before any driver call on every write path, leaves option visibleWhen, render and the unbound-root LOCKED arm untouched, and ships minor with the breaking carriers the launch window requires. Landing still waits on Lint & Repo Gates (in progress at the last read) and on the maintainer's answer to the classifier stop.


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 25, 2026 01:11
@os-litant
os-litant enabled auto-merge September 25, 2026 01:11
@os-litant
os-litant added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 5dba7f3 Sep 25, 2026
43 checks passed
@os-litant
os-litant deleted the claude/issue-19727-ui-predicate-fault-submit-refuses branch September 25, 2026 01:42
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…in both traversal refusals (objectstack-ai#20049)

Fixes objectstack-ai#20007

Clause-②: no

## What was wrong

An author who wants to refuse an order whose OPTIONAL `line` lookup
points at a secret line, and say nothing about an order with no line,
was sent in a circle by two refusals:

1. `record.line != null && record.line.kind == 'secret'` reads `line`
both through the relationship and as a plain value, so the formula
conflict check refuses it (in `@objectstack/lint` at authoring time and
in the engine at write time). That refusal prescribed "Compare the id
explicitly: write `record.line.id` for the value comparison".
2. `record.line.id != null && record.line.kind == 'secret'` also reads
through `line`. So an order with no line is refused before evaluation as
"no single related record" (`resolveTraversalScope` gets a
`no-reference` binding). That prescription said "Guard the rule on the
reference being set, make it required, or …" and named no spelling for
the guard.

The spellings that work, a `conditional` wrapper or `required: true`,
were named in neither refusal.

## What changed

Only the prescription text. Every refusal keeps its verdict, its
`VALIDATION_FAILED` error, its `rule_violation` field error and its
`constraint`, and the same writes are refused.

- `packages/formula/src/relationship-traversal.ts`: the
`bare-and-traversed` message now says `.id` compares ids and is not a
null guard. For a plain value that tests for empty, it names the guard
and `required: true`.
- `packages/objectql/src/validation/rule-validator.ts`, the
`no-reference` arm of `traversalRefusal`: names the guard through the
landed `referenceGuardRepair(field)`, says `record.FIELD.id != null` is
no guard, and names `required: true`. The multi-value macro clause is
kept.

Engine texts at this head, printed from the built
`@objectstack/objectql` dist. The runtime text spells `RELATED_FIELD`
below as the placeholder `related field` in angle brackets.

```text
[mixed shape, worded by formula] … To compare the id, write `record.line.id` for the value comparison, and keep `record.line.RELATED_FIELD` for the traversal. `record.line.id` is not a null guard: it reads through `line` too, and a rule that reads through an empty `line` rejects the write instead of being skipped. If the plain value tests for empty, take that test out of this expression. To skip the rule while `line` is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose `when` is `record.line != null`. To refuse an empty `line`, make `line` required (`required: true`).

[no single related record, worded by objectql] … A predicate resolves ONE hop through a single reference. To skip the rule while `line` is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose `when` is `record.line != null` — `record.line.id != null` inside the rule is no guard, as it reads through `line` too. To refuse an empty `line`, make `line` required (`required: true`). For a multi-value reference, test it with a macro (`exists`, `size`) instead of reading through it.
```

## Premise check and PM hypotheses (measured at `origin/main`
`b3735968ba`)

- **H1 holds.** The new `objectstack-ai#20007` block in
`engine-predicate-relationship.test.ts` was committed first
(`ae97576f7`) and run against the unchanged source: `2 failed | 2
passed`. Step 1: the natural spelling is refused as the mixed shape, and
the text lacked the guard. Step 2: `record.line.id != null && …` on an
empty `line` is refused before evaluation with a fault that ends `no
single related record`, and the text named no spelling. Both failures
were on text assertions. The code, field code and constraint assertions
above them passed.
- **H2 holds.** Two tests were green on the unchanged source, so the
text change is all this card needed:
- A `conditional` rule with `when: 'record.line != null'` wrapping
`record.line.kind == 'secret'` accepts an insert with no line, `line:
null` and a public line. It refuses a secret line with the rule's own
message (field `_record`, `rule_violation`, no `unevaluable`
constraint). On update, it refuses repointing an empty order at a secret
line and accepts clearing a set one.
- With `required: true`, an insert with no line is refused with exactly
one field error, `['line', 'required']`, because the rule is never
reached. A public line is accepted and a secret line is refused by the
rule.
- The wrapper parses as a spec `ValidationRule`
(`@objectstack/spec/data`, `safeParse` ok). Control: the same wrapper
without its `message` fails with `invalid_type` at `message`.
- **H3, the consumers of the formula text:** `findTraversalConflicts`
has two callers.
- `validateExpression` (formula) is used by `@objectstack/lint`'s
`validateStackExpressions` at `rule.condition`, the only site that
passes `traversalHydration: true`. Its output text changes. A probe
against the built lint dist gave: the natural spelling, one error with
the new text; the `.id` spelling, 0 issues (lint cannot know a reference
will be empty); the wrapped rule, 0 issues.
- ObjectQL's `resolveTraversalScope` passes the message through into
`detail`, so the engine text changes.
- Lint and objectql dists do not bundle the formula text: 0 hits for it
in `packages/lint/dist` and `packages/objectql/dist`. Control: 2 hits in
`packages/formula/dist`. The text ships from formula alone.
- Repo-wide grep for tests asserting the old texts ("Compare the id",
"no single related record", "MULTIPLE references", "make it required",
"value comparison" and more): hits only in the three suites this PR
edits. No lint test asserts the formula text.
- **H4, where the one wording lives:** formula may not import objectql,
and exporting the sentence from `@objectstack/formula` would add a
public export. That would widen the public surface, so the claim's
`Clause-②: no` would not hold. So the wording exists twice, and a test
holds the copies equal:
- formula has a module-local `referenceGuardRepair(root, field)`, not
exported;
- objectql keeps its landed module-local `referenceGuardRepair(field)`.
- Each docblock names the other copy and the pin.
`engine-predicate-relationship.test.ts` asserts one literal `GUARD` in
the engine refusal worded by formula (step 1) and in the one worded by
objectql (step 2).
- Ablation below: mutating only the formula copy turns step 1 red and
leaves step 2 green.

## Tests (at `0f4c443ac`)

- `@objectstack/formula`: `vitest run` 35 files / 978 passed;
`typecheck` exit 0.
- `@objectstack/objectql`:
- `--project local` 311 files / 5266 passed (run at `b8801356b`; since
then only one test's assertions changed, and that file re-ran 42/42 at
this head);
  - `test:repo` 5/5;
  - `typecheck` exit 0 (`check:test-typecheck` OK, ledger unchanged).
- `@objectstack/lint` (consumer): `vitest run` 108 files / 4138 passed,
with formula rebuilt.
- New pins:
- formula: the conflict message and the `validateExpression` refusal
name the guard and `required: true`, and both halves of the guarded rule
validate;
- objectql rule level: the `no-reference` case and the mixed case name
the guard;
  - objectql engine end to end: the four `objectstack-ai#20007` cases above.
- **Reverse verification:** the 2 red / 2 green run on the unchanged
source is quoted under H1.
- **Ablation** (through `dist`, because the objectql to formula import
is an unaliased pair in `KNOWN_UNALIASED_TEST_IMPORTS`).
`scripts/ablation-replace.mjs` rewrote `` whose `when` is ` `` to
`IS_ABLATED` in the formula copy only: anchor 1 to 0, blob
`002192dc9337` to `324bfd327a45`. After a formula build,
`ablation-dist-preflight` reported the marker present in 2 built files.
Results:
  - objectql step 1 red, steps 2 to 4 green (`1 failed | 3 passed`);
  - the two new formula text pins red (`2 failed | 30 passed`).
- **Restore:** the blob equals HEAD and `git diff HEAD` is empty. After
a rebuild, the marker is absent from all 6 built files and the tree is
clean. The pins are green again: objectql 4/4, formula 32/32.

## Gates

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `0f4c443ac` (merge base `b3735968b`)
derives 63 commands; 61 exit 0.
- **NOT MEASURED** (exit 3, PREREQUISITE NOT MET): `pnpm
check:dual-build-cjs-loads` and `pnpm check:type-check-debt`. Both read
the whole workspace's built `dist`, which needs a full `pnpm build` that
this seat did not run on the shared box. This diff changes string
literals and tests only. CI builds and runs both.
- `--ran`: `63 derived famil(ies) accounted for — 61 run, 2 NOT-MEASURED
(2 DERIVED from a recorded exit 3)`.
- `check:error-code-casing` first went red on a new test line
(`toMatchObject({ code: 'rule_violation' })`). The assertion now uses
the envelope shape the neighbouring pins use, and the gate exits 0.
- `node scripts/check-issue-citations.mjs --base b373596`: exit 0, 4
citations resolve.
- `pnpm check:nul-bytes`: exit 0.
- Narrowed ESLint at `0f4c443ac`: `eslint --no-inline-config --format
json` over the 5 changed `.ts` files gives 5 files, 0 errors and 0
warnings. `--print-config` shows each file is in the population with
`parserOptions.project` and `projectService` null. `eslint.config.mjs`
states that type-aware linting is never enabled, so this diff cannot
move a verdict on any untouched file. The full `pnpm lint` run belongs
to CI.

## Acceptance notes

- The pending `.changeset/18682-predicate-relationship-traversal.md`
table still reads "`record.account.id == 'acc_1'` for the value
comparison". That is correct for its example, which is an id comparison
and not a null test, so it is left alone.
- The shape "already holds an expanded record rather than an id" in the
`no-reference` arm still names no repair of its own. It is unchanged
here and outside this card.
- The engine's `ValidationError` carries `code: 'VALIDATION_FAILED'` and
`fields`, and no `status`. The REST layer maps the status, and this PR
does not change it.
- Out of this PR's surface, per the claim: `requiredWhen` /
`readonlyWhen` fault arms, `unevaluableRuleError`,
`packages/lint/src/validate-null-guards.ts` (draft PR objectstack-ai#20028) and
`packages/spec`.
- Changeset: `@objectstack/formula` patch and `@objectstack/objectql`
patch.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…can() in an option's visibleWhen (objectstack-ai#20079)

Fixes objectstack-ai#18783

Clause-②: yes (widening)

Executes ruling A on the card (maintainer 「同意」, comment 5725678115): the
census comes first, then the reader, then the wiring. The server now
answers `current_user.can(object, verb)` in an option's `visibleWhen`.
Before this PR the predicate faulted on every authenticated write and
the value was admitted unenforced. The interface member is the one PR
objectstack-ai#19622 declared:
`ISecurityService.getEffectiveObjectPermissions?(context?)`.
`packages/spec` is not touched.

**Declared cross-lane edits.** `packages/plugins/plugin-security` is
`domain:services` surface; the ruling places it on this card.
`packages/core` and `packages/plugins/plugin-hono-server` are outside
the claim's file surface. They are touched because the dispatch's H2
requires the `/auth/me/permissions` route and the member to read ONE
function, and `@objectstack/core` is the only package both already
depend on (plugin-hono-server must not take a runtime dependency on
plugin-security).

## What lands

- **objectql** gets a new engine seam,
`registerEffectiveObjectPermissionsResolver(fn)`. It is the same shape
as `registerWriteGateProbe`, which the security plugin already registers
on the engine.
- `resolveOptionPermissions` asks the resolver at most ONCE per write: a
batch insert, a by-id update, an N-row bulk update or a `validate()`
preview.
- It asks only when three things hold: a resolver is registered, the
write has an acting user, and some payload picks an option whose
`visibleWhen` calls `can`.
- The answer goes through formula's `toEvalPermissions`. A resolution
throw, or a map that is not the published shape, is re-raised untouched
for exactly the payloads that needed the map: the write fails CLOSED.
- **rule-validator**: `evaluateValidationRules` takes `permissions` and
passes it to the per-option `visibleWhen` evaluation.
- `optionVisibilityReadsPermissions` answers "does this write need the
map". It reads the parsed CEL AST for a receiver call named `can`, and
uses the same picker (`pickedGatedOptions`) the evaluator judges with.
- `undefined` stays "no permission data": the predicate is loudly
unevaluable and the existing fail-open branch admits the value with a
warn that names the missing input.
- **plugin-security** implements `getEffectiveObjectPermissions` on the
class and on the registered `security` literal, and registers the same
method on the engine.
- The member resolves the sets with `resolvePermissionSetsForContext`
and builds the map with `buildEffectiveObjectPermissions` over the
plugin's engine. It freezes the map at the top level.
  - A resolution failure propagates untouched and never becomes `{}`.
  - An engine without the seam gets one `warn` at start.
- **core** gets `buildEffectiveObjectPermissions`: the most-permissive
merge, then `seedSuperUserRestrictedObjects`, `foldWildcardSuperUser`,
`clampManagedObjectWrites` and `annotateEffectiveApiOperations`. The
four folds and `ManagedSchemaLike` / `ApiExposureSchemaLike` moved here
unchanged (reindented) from plugin-hono-server.
- **plugin-hono-server**: `/auth/me/permissions` builds its `objects`
slot with `buildEffectiveObjectPermissions`. The six moved names are
re-exported from `@objectstack/core`, so the package root still exports
them. ESM probe: the re-exported `foldWildcardSuperUser` is the same
function object as core's (`===`).

## Census — every in-repo evaluation site that binds `current_user`

The grep, over non-test source outside `packages/formula` (the engine
itself) and `packages/qa`:

```
git grep -n -E "\b(ExpressionEngine|celEngine|templateEngine)\.evaluate\b|\bresolveSeed(Record)?\(|\bcompileCelToFilter\(" -- 'packages/**/*.ts' ':!**/*.test.ts' ':!**/dist/**' ':!packages/formula/**' ':!packages/qa/**'
```

It gives 24 lines, 18 of them code (6 are comments). Completeness
control: a token scan for
`ExpressionEngine|celEngine|templateEngine|resolveSeedRecord|resolveSeed|buildScope|getEngine`
over the same tree gives 24 files. Every file outside the grep's
population mentions the tokens only in comments, or uses an unrelated
`getEngine`. Positive control: the grep's population contains the site
the ruling names (`rule-validator.ts` `evaluateOptionVisibility`).

| site (file : symbol) | binds `current_user` | author-reachable `can`
today | verdict |
|---|---|---|---|
| `objectql/src/validation/rule-validator.ts` :
`evaluateOptionVisibility` (reached from 5 engine call sites: insert,
by-id update, bulk update twice, `validate()`) | yes (`buildEvalUser`) |
yes. It faulted with "carries no permission data" and the fail-open
branch ADMITTED the value (measured on the base head, below). This is a
live violation and, by the ruling's own words, the p1 trigger. | **wired
here** |
| `objectql/src/engine.ts` : `applyFormulaPlan` (formula virtual fields,
read and write-back) | yes (own `{id, positions}`) | yes: value `null`
on read and on the insert echo, with NO log (measured at this head with
a resolver registered) | out of this card's plumbing: a value
expression, not a predicate, and it builds its own user object. Reported
as a finding. |
| `objectql/src/engine.ts` : `applyFieldDefaults` (CEL `defaultValue`) |
yes (own `{id, positions}`) | yes: field left unset, and `Failed to
evaluate default expression` names the missing input (measured) | out of
this card's plumbing, same reason. Reported as a finding. |
| `metadata-protocol/src/seed-loader.ts` : `resolveSeedRecord` | yes
(seed identity, or `{ id: null }`) | the record is dropped loudly
(`errored++`, an actionable error) | out of scope: boot-time replay with
no request and no acting subject whose grants would mean anything. The
loud refusal is the correct answer. |
| `plugin-security/src/rls-compiler.ts` : `compileExpressionOutcome`
(`compileCelToFilter`, `current_user` as a lowering variable) | as a
filter variable | `unsupported method "can()"`: the policy drops and RLS
denies (fail closed) | out of scope: an RLS predicate is lowered into a
driver filter, and a filter cannot consult a permission map |
| `lint/src/validate-rls-predicate-enforceability.ts` | probe variable |
authoring gate, not a runtime evaluation | out of the population |
| hook `condition` (`hook-wrappers.ts`), `readonlyWhen`, `requiredWhen`
x2, `script`, `conditional` (`rule-validator.ts`), approvals expression
approver, share-link eligibility, flow `celScope` x2, sharing-rule
seeder, lint sharing gate | **no** | n/a | outside the census:
`current_user` is not bound there |

## H1 — red on the base head, green here

Ruling pin, `rule-validator.option-visibility.test.ts`, run on the base
(`2274894cc`) before the fix:
- `REFUSES a can-gated option … withholds the verb` failed with
`expected undefined to be an instance of ValidationError` (admitted).
- `ADMITS it …` failed with `expected [ { …(2) } ] to have a length of
+0 but got 1` (the fail-open warn).
- The run ended `Tests 7 failed | 31 passed (38)`. The no-`can` control
and the no-permission-data case were green on the base, which is their
point.

With the fix: `Tests 38 passed (38)`.

## H2 — the producer, and byte-equality

The `/auth/me/permissions` merge lived inline in plugin-hono-server's
`current-user-endpoints.ts`. It is now
`buildEffectiveObjectPermissions`, read by both the route and the
member.
- **Route unchanged.** Whole response bodies from the base route and
this head's route, for the same resolved sets and schemas, are
byte-identical on five fixtures (super-user without export, super-user
with export, wall-less org admin, plain rep, nothing). Measured
one-shot: lengths 1461/381/633/336/168, all `equal=true`.
- **Permanent pins, both halves.**
`current-user-endpoints-effective-objects.test.ts` pins that the route's
`objects` equals `buildEffectiveObjectPermissions` over the resolved
sets, byte for byte. `get-effective-object-permissions.test.ts` pins
that the member equals the same function over
`resolvePermissionSetsForContext`, for three subjects. Both fixtures
make the seed, fold, clamp and annotate steps all fire.

## H3 — resolutions per write

- Bulk update across N matched rows: 1 resolution for N=1 and for N=25.
- Batch insert of 7 rows: 1 resolution.
- Two writes: 2 resolutions, and a grant revoked between them is refused
on the second, so nothing is kept across writes.
- A write whose gates never call `can` makes 0 resolutions, even with a
resolver that would throw. A system write makes 0.

The set resolution under the member is plugin-security's existing
per-context memo, keyed on the request's context object and retired by
the write epoch. Within one request context a second ask costs no second
set load. A new context loads again (pinned).

## Both failure directions (pinned)

- Resolver throws: the insert rejects with that very error object
(`code`, `status` intact), it is not a `ValidationError`, and nothing is
written.
- Off-shape map (`{ crm_account: true }`): `TypeError` from
`toEvalPermissions`, nothing written.
- No resolver: admitted, with one warn whose `meta.error.message`
contains `carries no permission data`. It is not denied.

**Ablation:** the insert call site's `permissions:
insertPermissionsFor(rows[i])` was replaced by `permissions: undefined
/* ABLATION-18783 */` through `scripts/ablation-replace.mjs`. The anchor
went 1 to 0, the marker 0 to 1, and the blob changed.
- 5 engine cases went red (refuse, admit, throw fails closed, off-shape
map, revocation); the 8 control and non-insert cases stayed green.
- The file was restored to its HEAD blob and `git diff HEAD` was empty.
- The first attempt used a replacement already present 178 times. The
tool refused it (count unchanged) and restored, so it was a no-op and
was re-run with the marker.

## Known gap — measured, not changed here

`can()` reads only per-object entries. `/auth/me/permissions`
materialises an entry for an object covered by `'*'` only when that
wildcard carries a super-user bit (the seed pass). With the shipped
`organization_admin_no_bypass` plus `member_default` (the grant a
deployment without an organization wall gives organization owners and
admins):
- the map has no `crm_account` entry;
- `current_user.can('crm_account', 'edit')` evaluates to `false`;
- `PermissionEvaluator.checkObjectPermission('update', 'crm_account',
sets)` is `true`.

`admin_full_access` and walled `organization_admin` answer `true` on
both sides. Before this PR that population's `can` gate was never
enforced for anyone. From this PR on, the server refuses them on a
`can`-gated option. The fix belongs to the map's producer (materialising
plain-wildcard coverage, which changes the `/auth/me/permissions`
response) or to formula's `can`. It is raised as a question in the
report, not decided here.

## Overlap with in-flight work

`rule-validator.ts`: this diff touches the `EvaluateRulesOptions`
interface (one new member), the `USER_SCOPE_ROOTS` docblock, the
`evaluateOptionVisibility` region (a new picker plus helpers), and ONE
line inside `evaluateValidationRules`'s body: the
`evaluateOptionVisibility(...)` call gains `opts.permissions`. That call
is not the function's head, the `requiredWhen` / `readonlyWhen` arms or
`unevaluableRuleError`, which are draft PR objectstack-ai#20028's region.
`traversalRefusal` (PR objectstack-ai#20049) is already on `main` and was merged in
here without a conflict.

## Verification (at `f3fe6d6cfd`)

- Suites: objectql `test` 312 files / 5292 tests and `test:repo` 1/5;
plugin-security 134 / 2658; plugin-hono-server 27 / 313 (+1 todo); core
`test` 53 / 1331 and `test:repo` 3 / 48. All passed. The objectql,
plugin-hono-server and core runs are from the merge commit `b5416a2b49`;
the two later commits touch only plugin-security's new test file, and
plugin-security's suite and typecheck were re-run at `f3fe6d6cfd`.
- `typecheck` passed for core, objectql, plugin-security and
plugin-hono-server. Each new test file is in a tsc program
(`--listFiles`, via `tsconfig.test.json` or the main config).
- The four packages build (`check-dts-emitted` present). CJS and ESM
load probes see the new core exports and the hono re-exports.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 72 commands, and all were run at
`f3fe6d6cfd`. 69 exited 0.
- 3 exited 3 with PREREQUISITE NOT MET, so they are NOT MEASURED:
`check:dual-build-cjs-loads`, `check:type-check-debt` (both need the
whole workspace built) and `check:i18n` (needs the CLI build closure).
`--ran` reconciliation: 72 derived, 69 run, 3 NOT-MEASURED, 0 UNRUN.
- `check-issue-citations --base b76aad5`: every citation this change
adds resolves.
- Narrowed eslint (`--no-inline-config`, `--format json`) on the 11
changed `.ts` files: 11 files, 0 errors, 0 warnings. `eslint.config.mjs`
enables no type-aware linting (no `parserOptions.project`), so the diff
cannot move a verdict on an untouched file.

## Acceptance notes

- plugin-hono-server's pin batteries for the four folds
(`fold-wildcard-superuser.test.ts`, `effective-api-operations.test.ts`)
stay where they are. They exercise the folds through that package's
unchanged re-exports. core carries its own composition pins.
- The route's failure stance is unchanged: a set-resolution failure
still answers `objects: {}` (its pre-existing `.catch(() => [])`). The
member's stance differs on purpose: it throws.
- `check-changeset-no-major`'s Clause-② level axis reports NOT
APPLICABLE locally (there is no `pull_request` payload). CI reads it.


## Seat amendment (5825601266)

- `Clause-②` is corrected from the claim's `no` to `yes (widening)`. The
diff adds public exports to `@objectstack/core`
(`buildEffectiveObjectPermissions`, plus the folds and types moved from
`plugin-hono-server`) and a public
`ObjectQL.registerEffectiveObjectPermissionsResolver`.
- The widened file surface (`packages/core`, and `plugin-hono-server`,
which is `domain:cli` surface) is accepted as the single-producer
consequence of the member's "computed once" rule.
- The open questions are answered A (land, and fix the plain-wildcard
gap at the producer, as its own card), B (this line) and A (keep
`Fixes`; the value-expression rows are filed as their own card).
- objectstack-ai#18783 is re-graded to p1 per ruling A's census clause.


## Patch round 1 (merge-queue failure, `7a6b091b2a`)

- **What failed:** the queue removed this PR on `Test Core (1/6)` /
`Spec property liveness` (queue run 36088336600). The spec liveness gate
reported `permission/objects.allowExport` UNANCHORED, because its
evidence cited `plugin-hono-server/src/current-user-endpoints.ts`,
which, after this PR moved `annotateEffectiveApiOperations` into
`@objectstack/core`, no longer names `allowExport` (0 mentions; the new
file has 7). PR CI runs only the affected subset, and the queue runs the
full suite.
- **Fix:** one ledger file, `packages/spec/liveness/permission.json`.
The citation is repointed to
`packages/core/src/security/effective-object-permissions.ts#annotateEffectiveApiOperations`,
with `verifiedAt` 2026-09-25 and a dated note. It is a declared
cross-lane pointer update in the spec LEDGER, not in `src`.
  - `check:liveness` went from exit 1 (`1 UNANCHORED`) to exit 0.
- `check-liveness.test.ts` went from 20 failed / 44 passed to 64 passed.
- The sweep for other pointers made false by the move found none. The
two governed ADR mentions (0103, 0124) are still true and were not
edited.
- The delta contract review is recorded against this head.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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