docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks
Sep 3, 2026
Merged

docs(spec): state the transition-gate vs invariant boundary at each declaration#14889
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#13879

Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so. Field.requiredWhen and the field bounds (min / max / minLength / maxLength) are transition gates; a validations[]script rule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.

The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.

The premise, re-measured against origin/main before a word was written

The card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over evaluateValidationRules / validateRecord (12 cases, all green; the file was deleted before the commit — this PR adds no test to packages/objectql).

CaseEngine behaviourCode path / existing pin
requiredWhen: INSERT born inside the gate, value absentREFUSEDrule-validator.tsevaluateValidationRules, field-rule block; pinned by rule-validator.test.ts "rejects a missing required-when field (insert, predicate TRUE)"
requiredWhen: draft to presented, value absentREFUSED — merged violates, pre-state compliedsame block; pinned by "update that CREATES the violation is rejected"
requiredWhen: clearing the cell while inside the gateREFUSEDsame block; pinned by "update that nulls the field out under a TRUE condition is rejected"
requiredWhen: in-gate move presented to accepted on a row already missing the valuePASSESpreViolated short-circuitsame block (if (preViolated) continue; // legacy rows rest)
requiredWhen: unrelated edit on a row already missing the valuePASSESsame; pinned by "legacy rows rest: a pre-existing violation does not block an unrelated write"
requiredWhen: repairing writePASSESpinned by "a write that FIXES the legacy violation passes"
bound max: write carrying an out-of-bound valueREFUSED (max_value)record-validator.tsvalidateOne, number branch
bound max: unrelated edit while a stored out-of-bound value standsPASSES — the stored value is never re-readvalidateRecord's UPDATE branch iterates Object.entries(data), the payload, not the field map
bound max: repairing writePASSESsame branch
script: unrelated edit while a stored violating value standsREFUSED — frozencheckPredicate over ctx.merged, no pre-state exemption
script: repairing writePASSES — not brickedsame; the merged record no longer violates
script: INSERT that violatesREFUSEDsame

One refinement over the card's wording, and it is load-bearing for the text.requiredWhen is not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like a script rule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason — validateRecord iterates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.

The sentences added, per site

All four sites are .describe() text, so each ships as the JSON Schema description and as the generated reference page — the copy an author actually reads.

FieldSchema.requiredWhen (packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":

A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[]script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules.

min / max — the bare "Minimum value" / "Maximum value" now carry:

Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[]script rule instead.

(max says the same with "above" and refers to min for the class.)

minLength / maxLength — one sentence each placing them in that class:

Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused.

InlineGridColumnSchema.requiredWhen — the trap next door, measured rather than assumed: nothing on the write path reads inlineColumns at all (driver-sql classifies the key presentation, and no consumer outside the renderer exists).

PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own requiredWhen — a transition gate, see Field.requiredWhen — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.

ScriptValidationSchema.condition (packages/spec/src/data/validation.zod.ts):

A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use Field.requiredWhen or a field bound (min / max / maxLength), which judge the write rather than the stored row.

Module docblock (validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules, validateRecord). It renders into content/docs/references/data/validation.mdx.

cross_field needs nothing of its own: the file already states it "shares the exact evaluation path as script", and the new section names it.

Deliverable 3: named, not edited

The card's optional third deliverable was an implementation note in packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text names evaluateValidationRules (and validateRecord) by symbol so a reader can follow it.

Proposed skills tool-choice line (for the follow-up card)

Deliverable 2 of the card is the tool-choice line in the published skills/**. That surface is domain:skills and governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:

Need an invariant — a condition every stored record must satisfy ("discount may never exceed 60") — declare it as a validations[]script rule: it is re-checked against the merged record on every write, so a row that already violates it is refused on any edit until a repairing write lands (frozen, not bricked).
Need a transition condition — something that must hold from a given point onward ("a quote must name a contact by the time it is presented") — use requiredWhen or a field bound (min / max / maxLength): these judge the write, not the stored row, so rows that predate the rule keep passing unrelated edits. "Required when X" reads like an invariant and is not one.

The pin

packages/spec/src/data/validation-boundary-description.test.ts — 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.description and siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.

Reverse verification (expected direction: RED). With the fix committed first, A TRANSITION GATE, not an invariant: was replaced by A conditional requirement: in field.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12 to 45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored with git checkout HEAD -- under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to 294c9e12 and an empty git diff HEAD. No dist rebuild is involved in this ablation: the pin imports ./field.zod as a same-package relative source path, which vitest transforms from src — not through the package exports into dist.

Regenerated artifacts

pnpm --filter @objectstack/spec check:generated --fix reported exactly one stale artifact and regenerated only it (gen:docs):

  • content/docs/references/data/field.mdx
  • content/docs/references/data/object.mdx
  • content/docs/references/data/validation.mdx
  • content/docs/references/system/migration.mdx (the add_field migration op embeds the field schema)

Every changed line in those four files is one of the new sentences — verified by filtering the diff.

Verification record — union run at e8cd5aa2

Heavy runs went through scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.

RunResult
pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0
pnpm --filter @objectstack/spec buildVERDICT command-exit 0, 34/34 declared declaration file(s) present
pnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459); Tests 12294 passed, 1 skipped (12295)
pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0; check:test-typecheck: OK
new pin aloneTest Files 1 passed, Tests 18 passed
engine measurement (scratch, deleted)Tests 12 passed (12) alongside rule-validator.test.ts + record-validator.test.ts, Tests 293 passed (293)
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — no narrowing claimed
pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)

Gate family re-derived on the actual changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths, three-dot against merge base 2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:

  • node scripts/check-dev-prereqs.mjs — "The workspace is not built — 1 unmet precondition, not a list of problems."
  • node scripts/check-test-completeness.mjs (exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt (exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."

Seven gates that read packages/spec/dist went red on the first pass purely on mtime — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src. All seven are green after rebuilding (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples; the last also needed the @objectstack/client-react closure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.

Scope

packages/spec only, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves; packages/objectql is not touched. Per the region split with #13894, this PR writes only the requiredWhen / bounds describe texts in field.zod.ts — the unique line, the autonumber block and RUNTIME_OWNED_FIELD_TYPES are untouched, and that card rebases on this one. A patch changeset for @objectstack/spec is included, so no skip-changeset label.

One side finding, filed unassigned rather than fixed here: #14891 — the script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin in rule-validator.test.ts, while the requiredWhen side's mirror case does.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE

Generated by Claude Code


Generated by Claude Code

…eclaration
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`)
are transition gates; a `validations[]` `script` rule is a true invariant. Both
semantics are deliberate and neither moves here — what was missing is that no
platform surface said so, while "required when X" reads as an invariant.
Measured against the engine before writing a word (12 cases, all green):
`evaluateValidationRules` refuses a `requiredWhen` write only when the merged
record violates AND the pre-write record complied (the ADR-0113 non-regression
branch), `validateRecord` iterates the UPDATE payload so a bound is checked on
the written value only, and `checkPredicate` re-evaluates a `script` rule
against the merged record with no pre-state exemption.
Adds a schema-reachable pin over the contract text, and regenerates the
reference pages the `.describe()` strings feed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx(via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17.mdx(via FieldSchema (symbol, a top-level const))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8be6516df9f696135ac29668afa4083a93e83194 — the merge of head 682b107e4a187b1a23e143b2840011712fa6e801 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

CI on e8cd5aa21 (seat reading, 08:19Z): Test Core (1/6) (job 100569643525, run 33730657230) is the one red of 36 checks — and it is not this PR's failure.

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 180104 ms; case 1 measured the same child at 5899 ms on this runner minutes earlier: expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)

That is #14832 — the os dev hang in an unbuilt workspace, a packages/cli product defect (pm:dispatched to the domain:cli seat; the quarantine PR #14871 under maintainer ruling A is in the merge queue but not on main yet, so there is nothing to port). This PR's diff is packages/spec describe text, one pin, a changeset and four regenerated reference pages; it does not touch packages/cli, and every other shard is green. Failed jobs re-run once (MCP rerun_failed_jobs, requested in the same stroke as this note) — the one re-run the seat's rule allows. Green ⇒ the landing path (trial merge is already clean vs 2263ca4d6, NOT governed 0/8, ACCEPT 5522546337 on #13879). Red again on the same line ⇒ no second re-run: card #13879 goes pm:blocked on #14832 with Unlock-action: re-check PR #14889, and the branch gets origin/main merged in once #14871 lands (a re-run replays the old merge commit).


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):

  • CI on head 682b107e4: 34 checks, 0 red, 0 pending — the Test Core (1/6) hang is quarantined on main (PR test(cli): quarantine the never-read-reader hang case behind #14832 (maintainer ruling A, 2026-09-03) #14871, 392f41089), so the shard that redded the previous head is green here.
  • Trial merge against origin/main7251bad7d: clean (tree 7f9d9c095).
  • Governed-surface read on the final 8-file list: NOT governed (0 of 8 hit the register) — ordinary queue landing applies.
  • Contract review: Clause ② no; seat ACCEPT 5522546337 (the merge commit added only main's own commits, the reviewed diff is unchanged).

⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ pm:blocked on the anchor card with Unlock-action: re-check PR #14889.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 3, 2026 09:05
@os-sam
os-sam enabled auto-merge September 3, 2026 09:05
@os-sam
os-sam added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0169d49Sep 3, 2026
36 checks passed
@os-sam
os-sam deleted the claude/issue-13879-required-when-transition-gate-docblocks branch September 3, 2026 11:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks
Sep 3, 2026
Merged

docs(spec): state the transition-gate vs invariant boundary at each declaration#14889
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#13879

Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so. Field.requiredWhen and the field bounds (min / max / minLength / maxLength) are transition gates; a validations[]script rule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.

The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.

The premise, re-measured against origin/main before a word was written

The card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over evaluateValidationRules / validateRecord (12 cases, all green; the file was deleted before the commit — this PR adds no test to packages/objectql).

CaseEngine behaviourCode path / existing pin
requiredWhen: INSERT born inside the gate, value absentREFUSEDrule-validator.tsevaluateValidationRules, field-rule block; pinned by rule-validator.test.ts "rejects a missing required-when field (insert, predicate TRUE)"
requiredWhen: draft to presented, value absentREFUSED — merged violates, pre-state compliedsame block; pinned by "update that CREATES the violation is rejected"
requiredWhen: clearing the cell while inside the gateREFUSEDsame block; pinned by "update that nulls the field out under a TRUE condition is rejected"
requiredWhen: in-gate move presented to accepted on a row already missing the valuePASSESpreViolated short-circuitsame block (if (preViolated) continue; // legacy rows rest)
requiredWhen: unrelated edit on a row already missing the valuePASSESsame; pinned by "legacy rows rest: a pre-existing violation does not block an unrelated write"
requiredWhen: repairing writePASSESpinned by "a write that FIXES the legacy violation passes"
bound max: write carrying an out-of-bound valueREFUSED (max_value)record-validator.tsvalidateOne, number branch
bound max: unrelated edit while a stored out-of-bound value standsPASSES — the stored value is never re-readvalidateRecord's UPDATE branch iterates Object.entries(data), the payload, not the field map
bound max: repairing writePASSESsame branch
script: unrelated edit while a stored violating value standsREFUSED — frozencheckPredicate over ctx.merged, no pre-state exemption
script: repairing writePASSES — not brickedsame; the merged record no longer violates
script: INSERT that violatesREFUSEDsame

One refinement over the card's wording, and it is load-bearing for the text.requiredWhen is not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like a script rule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason — validateRecord iterates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.

The sentences added, per site

All four sites are .describe() text, so each ships as the JSON Schema description and as the generated reference page — the copy an author actually reads.

FieldSchema.requiredWhen (packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":

A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[]script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules.

min / max — the bare "Minimum value" / "Maximum value" now carry:

Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[]script rule instead.

(max says the same with "above" and refers to min for the class.)

minLength / maxLength — one sentence each placing them in that class:

Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused.

InlineGridColumnSchema.requiredWhen — the trap next door, measured rather than assumed: nothing on the write path reads inlineColumns at all (driver-sql classifies the key presentation, and no consumer outside the renderer exists).

PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own requiredWhen — a transition gate, see Field.requiredWhen — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.

ScriptValidationSchema.condition (packages/spec/src/data/validation.zod.ts):

A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use Field.requiredWhen or a field bound (min / max / maxLength), which judge the write rather than the stored row.

Module docblock (validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules, validateRecord). It renders into content/docs/references/data/validation.mdx.

cross_field needs nothing of its own: the file already states it "shares the exact evaluation path as script", and the new section names it.

Deliverable 3: named, not edited

The card's optional third deliverable was an implementation note in packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text names evaluateValidationRules (and validateRecord) by symbol so a reader can follow it.

Proposed skills tool-choice line (for the follow-up card)

Deliverable 2 of the card is the tool-choice line in the published skills/**. That surface is domain:skills and governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:

Need an invariant — a condition every stored record must satisfy ("discount may never exceed 60") — declare it as a validations[]script rule: it is re-checked against the merged record on every write, so a row that already violates it is refused on any edit until a repairing write lands (frozen, not bricked).
Need a transition condition — something that must hold from a given point onward ("a quote must name a contact by the time it is presented") — use requiredWhen or a field bound (min / max / maxLength): these judge the write, not the stored row, so rows that predate the rule keep passing unrelated edits. "Required when X" reads like an invariant and is not one.

The pin

packages/spec/src/data/validation-boundary-description.test.ts — 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.description and siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.

Reverse verification (expected direction: RED). With the fix committed first, A TRANSITION GATE, not an invariant: was replaced by A conditional requirement: in field.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12 to 45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored with git checkout HEAD -- under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to 294c9e12 and an empty git diff HEAD. No dist rebuild is involved in this ablation: the pin imports ./field.zod as a same-package relative source path, which vitest transforms from src — not through the package exports into dist.

Regenerated artifacts

pnpm --filter @objectstack/spec check:generated --fix reported exactly one stale artifact and regenerated only it (gen:docs):

  • content/docs/references/data/field.mdx
  • content/docs/references/data/object.mdx
  • content/docs/references/data/validation.mdx
  • content/docs/references/system/migration.mdx (the add_field migration op embeds the field schema)

Every changed line in those four files is one of the new sentences — verified by filtering the diff.

Verification record — union run at e8cd5aa2

Heavy runs went through scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.

RunResult
pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0
pnpm --filter @objectstack/spec buildVERDICT command-exit 0, 34/34 declared declaration file(s) present
pnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459); Tests 12294 passed, 1 skipped (12295)
pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0; check:test-typecheck: OK
new pin aloneTest Files 1 passed, Tests 18 passed
engine measurement (scratch, deleted)Tests 12 passed (12) alongside rule-validator.test.ts + record-validator.test.ts, Tests 293 passed (293)
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — no narrowing claimed
pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)

Gate family re-derived on the actual changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths, three-dot against merge base 2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:

  • node scripts/check-dev-prereqs.mjs — "The workspace is not built — 1 unmet precondition, not a list of problems."
  • node scripts/check-test-completeness.mjs (exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt (exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."

Seven gates that read packages/spec/dist went red on the first pass purely on mtime — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src. All seven are green after rebuilding (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples; the last also needed the @objectstack/client-react closure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.

Scope

packages/spec only, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves; packages/objectql is not touched. Per the region split with #13894, this PR writes only the requiredWhen / bounds describe texts in field.zod.ts — the unique line, the autonumber block and RUNTIME_OWNED_FIELD_TYPES are untouched, and that card rebases on this one. A patch changeset for @objectstack/spec is included, so no skip-changeset label.

One side finding, filed unassigned rather than fixed here: #14891 — the script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin in rule-validator.test.ts, while the requiredWhen side's mirror case does.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE

Generated by Claude Code


Generated by Claude Code

…eclaration
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`)
are transition gates; a `validations[]` `script` rule is a true invariant. Both
semantics are deliberate and neither moves here — what was missing is that no
platform surface said so, while "required when X" reads as an invariant.
Measured against the engine before writing a word (12 cases, all green):
`evaluateValidationRules` refuses a `requiredWhen` write only when the merged
record violates AND the pre-write record complied (the ADR-0113 non-regression
branch), `validateRecord` iterates the UPDATE payload so a bound is checked on
the written value only, and `checkPredicate` re-evaluates a `script` rule
against the merged record with no pre-state exemption.
Adds a schema-reachable pin over the contract text, and regenerates the
reference pages the `.describe()` strings feed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx(via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17.mdx(via FieldSchema (symbol, a top-level const))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8be6516df9f696135ac29668afa4083a93e83194 — the merge of head 682b107e4a187b1a23e143b2840011712fa6e801 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

CI on e8cd5aa21 (seat reading, 08:19Z): Test Core (1/6) (job 100569643525, run 33730657230) is the one red of 36 checks — and it is not this PR's failure.

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 180104 ms; case 1 measured the same child at 5899 ms on this runner minutes earlier: expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)

That is #14832 — the os dev hang in an unbuilt workspace, a packages/cli product defect (pm:dispatched to the domain:cli seat; the quarantine PR #14871 under maintainer ruling A is in the merge queue but not on main yet, so there is nothing to port). This PR's diff is packages/spec describe text, one pin, a changeset and four regenerated reference pages; it does not touch packages/cli, and every other shard is green. Failed jobs re-run once (MCP rerun_failed_jobs, requested in the same stroke as this note) — the one re-run the seat's rule allows. Green ⇒ the landing path (trial merge is already clean vs 2263ca4d6, NOT governed 0/8, ACCEPT 5522546337 on #13879). Red again on the same line ⇒ no second re-run: card #13879 goes pm:blocked on #14832 with Unlock-action: re-check PR #14889, and the branch gets origin/main merged in once #14871 lands (a re-run replays the old merge commit).


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):

  • CI on head 682b107e4: 34 checks, 0 red, 0 pending — the Test Core (1/6) hang is quarantined on main (PR test(cli): quarantine the never-read-reader hang case behind #14832 (maintainer ruling A, 2026-09-03) #14871, 392f41089), so the shard that redded the previous head is green here.
  • Trial merge against origin/main7251bad7d: clean (tree 7f9d9c095).
  • Governed-surface read on the final 8-file list: NOT governed (0 of 8 hit the register) — ordinary queue landing applies.
  • Contract review: Clause ② no; seat ACCEPT 5522546337 (the merge commit added only main's own commits, the reviewed diff is unchanged).

⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ pm:blocked on the anchor card with Unlock-action: re-check PR #14889.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 3, 2026 09:05
@os-sam
os-sam enabled auto-merge September 3, 2026 09:05
@os-sam
os-sam added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0169d49Sep 3, 2026
36 checks passed
@os-sam
os-sam deleted the claude/issue-13879-required-when-transition-gate-docblocks branch September 3, 2026 11:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks
Sep 3, 2026
Merged

docs(spec): state the transition-gate vs invariant boundary at each declaration#14889
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#13879

Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so. Field.requiredWhen and the field bounds (min / max / minLength / maxLength) are transition gates; a validations[]script rule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.

The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.

The premise, re-measured against origin/main before a word was written

The card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over evaluateValidationRules / validateRecord (12 cases, all green; the file was deleted before the commit — this PR adds no test to packages/objectql).

CaseEngine behaviourCode path / existing pin
requiredWhen: INSERT born inside the gate, value absentREFUSEDrule-validator.tsevaluateValidationRules, field-rule block; pinned by rule-validator.test.ts "rejects a missing required-when field (insert, predicate TRUE)"
requiredWhen: draft to presented, value absentREFUSED — merged violates, pre-state compliedsame block; pinned by "update that CREATES the violation is rejected"
requiredWhen: clearing the cell while inside the gateREFUSEDsame block; pinned by "update that nulls the field out under a TRUE condition is rejected"
requiredWhen: in-gate move presented to accepted on a row already missing the valuePASSESpreViolated short-circuitsame block (if (preViolated) continue; // legacy rows rest)
requiredWhen: unrelated edit on a row already missing the valuePASSESsame; pinned by "legacy rows rest: a pre-existing violation does not block an unrelated write"
requiredWhen: repairing writePASSESpinned by "a write that FIXES the legacy violation passes"
bound max: write carrying an out-of-bound valueREFUSED (max_value)record-validator.tsvalidateOne, number branch
bound max: unrelated edit while a stored out-of-bound value standsPASSES — the stored value is never re-readvalidateRecord's UPDATE branch iterates Object.entries(data), the payload, not the field map
bound max: repairing writePASSESsame branch
script: unrelated edit while a stored violating value standsREFUSED — frozencheckPredicate over ctx.merged, no pre-state exemption
script: repairing writePASSES — not brickedsame; the merged record no longer violates
script: INSERT that violatesREFUSEDsame

One refinement over the card's wording, and it is load-bearing for the text.requiredWhen is not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like a script rule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason — validateRecord iterates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.

The sentences added, per site

All four sites are .describe() text, so each ships as the JSON Schema description and as the generated reference page — the copy an author actually reads.

FieldSchema.requiredWhen (packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":

A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[]script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules.

min / max — the bare "Minimum value" / "Maximum value" now carry:

Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[]script rule instead.

(max says the same with "above" and refers to min for the class.)

minLength / maxLength — one sentence each placing them in that class:

Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused.

InlineGridColumnSchema.requiredWhen — the trap next door, measured rather than assumed: nothing on the write path reads inlineColumns at all (driver-sql classifies the key presentation, and no consumer outside the renderer exists).

PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own requiredWhen — a transition gate, see Field.requiredWhen — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.

ScriptValidationSchema.condition (packages/spec/src/data/validation.zod.ts):

A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use Field.requiredWhen or a field bound (min / max / maxLength), which judge the write rather than the stored row.

Module docblock (validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules, validateRecord). It renders into content/docs/references/data/validation.mdx.

cross_field needs nothing of its own: the file already states it "shares the exact evaluation path as script", and the new section names it.

Deliverable 3: named, not edited

The card's optional third deliverable was an implementation note in packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text names evaluateValidationRules (and validateRecord) by symbol so a reader can follow it.

Proposed skills tool-choice line (for the follow-up card)

Deliverable 2 of the card is the tool-choice line in the published skills/**. That surface is domain:skills and governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:

Need an invariant — a condition every stored record must satisfy ("discount may never exceed 60") — declare it as a validations[]script rule: it is re-checked against the merged record on every write, so a row that already violates it is refused on any edit until a repairing write lands (frozen, not bricked).
Need a transition condition — something that must hold from a given point onward ("a quote must name a contact by the time it is presented") — use requiredWhen or a field bound (min / max / maxLength): these judge the write, not the stored row, so rows that predate the rule keep passing unrelated edits. "Required when X" reads like an invariant and is not one.

The pin

packages/spec/src/data/validation-boundary-description.test.ts — 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.description and siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.

Reverse verification (expected direction: RED). With the fix committed first, A TRANSITION GATE, not an invariant: was replaced by A conditional requirement: in field.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12 to 45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored with git checkout HEAD -- under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to 294c9e12 and an empty git diff HEAD. No dist rebuild is involved in this ablation: the pin imports ./field.zod as a same-package relative source path, which vitest transforms from src — not through the package exports into dist.

Regenerated artifacts

pnpm --filter @objectstack/spec check:generated --fix reported exactly one stale artifact and regenerated only it (gen:docs):

  • content/docs/references/data/field.mdx
  • content/docs/references/data/object.mdx
  • content/docs/references/data/validation.mdx
  • content/docs/references/system/migration.mdx (the add_field migration op embeds the field schema)

Every changed line in those four files is one of the new sentences — verified by filtering the diff.

Verification record — union run at e8cd5aa2

Heavy runs went through scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.

RunResult
pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0
pnpm --filter @objectstack/spec buildVERDICT command-exit 0, 34/34 declared declaration file(s) present
pnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459); Tests 12294 passed, 1 skipped (12295)
pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0; check:test-typecheck: OK
new pin aloneTest Files 1 passed, Tests 18 passed
engine measurement (scratch, deleted)Tests 12 passed (12) alongside rule-validator.test.ts + record-validator.test.ts, Tests 293 passed (293)
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — no narrowing claimed
pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)

Gate family re-derived on the actual changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths, three-dot against merge base 2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:

  • node scripts/check-dev-prereqs.mjs — "The workspace is not built — 1 unmet precondition, not a list of problems."
  • node scripts/check-test-completeness.mjs (exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt (exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."

Seven gates that read packages/spec/dist went red on the first pass purely on mtime — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src. All seven are green after rebuilding (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples; the last also needed the @objectstack/client-react closure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.

Scope

packages/spec only, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves; packages/objectql is not touched. Per the region split with #13894, this PR writes only the requiredWhen / bounds describe texts in field.zod.ts — the unique line, the autonumber block and RUNTIME_OWNED_FIELD_TYPES are untouched, and that card rebases on this one. A patch changeset for @objectstack/spec is included, so no skip-changeset label.

One side finding, filed unassigned rather than fixed here: #14891 — the script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin in rule-validator.test.ts, while the requiredWhen side's mirror case does.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE

Generated by Claude Code


Generated by Claude Code

…eclaration
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`)
are transition gates; a `validations[]` `script` rule is a true invariant. Both
semantics are deliberate and neither moves here — what was missing is that no
platform surface said so, while "required when X" reads as an invariant.
Measured against the engine before writing a word (12 cases, all green):
`evaluateValidationRules` refuses a `requiredWhen` write only when the merged
record violates AND the pre-write record complied (the ADR-0113 non-regression
branch), `validateRecord` iterates the UPDATE payload so a bound is checked on
the written value only, and `checkPredicate` re-evaluates a `script` rule
against the merged record with no pre-state exemption.
Adds a schema-reachable pin over the contract text, and regenerates the
reference pages the `.describe()` strings feed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx(via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17.mdx(via FieldSchema (symbol, a top-level const))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8be6516df9f696135ac29668afa4083a93e83194 — the merge of head 682b107e4a187b1a23e143b2840011712fa6e801 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

CI on e8cd5aa21 (seat reading, 08:19Z): Test Core (1/6) (job 100569643525, run 33730657230) is the one red of 36 checks — and it is not this PR's failure.

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 180104 ms; case 1 measured the same child at 5899 ms on this runner minutes earlier: expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)

That is #14832 — the os dev hang in an unbuilt workspace, a packages/cli product defect (pm:dispatched to the domain:cli seat; the quarantine PR #14871 under maintainer ruling A is in the merge queue but not on main yet, so there is nothing to port). This PR's diff is packages/spec describe text, one pin, a changeset and four regenerated reference pages; it does not touch packages/cli, and every other shard is green. Failed jobs re-run once (MCP rerun_failed_jobs, requested in the same stroke as this note) — the one re-run the seat's rule allows. Green ⇒ the landing path (trial merge is already clean vs 2263ca4d6, NOT governed 0/8, ACCEPT 5522546337 on #13879). Red again on the same line ⇒ no second re-run: card #13879 goes pm:blocked on #14832 with Unlock-action: re-check PR #14889, and the branch gets origin/main merged in once #14871 lands (a re-run replays the old merge commit).


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):

  • CI on head 682b107e4: 34 checks, 0 red, 0 pending — the Test Core (1/6) hang is quarantined on main (PR test(cli): quarantine the never-read-reader hang case behind #14832 (maintainer ruling A, 2026-09-03) #14871, 392f41089), so the shard that redded the previous head is green here.
  • Trial merge against origin/main7251bad7d: clean (tree 7f9d9c095).
  • Governed-surface read on the final 8-file list: NOT governed (0 of 8 hit the register) — ordinary queue landing applies.
  • Contract review: Clause ② no; seat ACCEPT 5522546337 (the merge commit added only main's own commits, the reviewed diff is unchanged).

⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ pm:blocked on the anchor card with Unlock-action: re-check PR #14889.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 3, 2026 09:05
@os-sam
os-sam enabled auto-merge September 3, 2026 09:05
@os-sam
os-sam added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0169d49Sep 3, 2026
36 checks passed
@os-sam
os-sam deleted the claude/issue-13879-required-when-transition-gate-docblocks branch September 3, 2026 11:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks
Sep 3, 2026
Merged

docs(spec): state the transition-gate vs invariant boundary at each declaration#14889
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#13879

Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so. Field.requiredWhen and the field bounds (min / max / minLength / maxLength) are transition gates; a validations[]script rule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.

The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.

The premise, re-measured against origin/main before a word was written

The card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over evaluateValidationRules / validateRecord (12 cases, all green; the file was deleted before the commit — this PR adds no test to packages/objectql).

CaseEngine behaviourCode path / existing pin
requiredWhen: INSERT born inside the gate, value absentREFUSEDrule-validator.tsevaluateValidationRules, field-rule block; pinned by rule-validator.test.ts "rejects a missing required-when field (insert, predicate TRUE)"
requiredWhen: draft to presented, value absentREFUSED — merged violates, pre-state compliedsame block; pinned by "update that CREATES the violation is rejected"
requiredWhen: clearing the cell while inside the gateREFUSEDsame block; pinned by "update that nulls the field out under a TRUE condition is rejected"
requiredWhen: in-gate move presented to accepted on a row already missing the valuePASSESpreViolated short-circuitsame block (if (preViolated) continue; // legacy rows rest)
requiredWhen: unrelated edit on a row already missing the valuePASSESsame; pinned by "legacy rows rest: a pre-existing violation does not block an unrelated write"
requiredWhen: repairing writePASSESpinned by "a write that FIXES the legacy violation passes"
bound max: write carrying an out-of-bound valueREFUSED (max_value)record-validator.tsvalidateOne, number branch
bound max: unrelated edit while a stored out-of-bound value standsPASSES — the stored value is never re-readvalidateRecord's UPDATE branch iterates Object.entries(data), the payload, not the field map
bound max: repairing writePASSESsame branch
script: unrelated edit while a stored violating value standsREFUSED — frozencheckPredicate over ctx.merged, no pre-state exemption
script: repairing writePASSES — not brickedsame; the merged record no longer violates
script: INSERT that violatesREFUSEDsame

One refinement over the card's wording, and it is load-bearing for the text.requiredWhen is not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like a script rule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason — validateRecord iterates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.

The sentences added, per site

All four sites are .describe() text, so each ships as the JSON Schema description and as the generated reference page — the copy an author actually reads.

FieldSchema.requiredWhen (packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":

A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[]script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules.

min / max — the bare "Minimum value" / "Maximum value" now carry:

Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[]script rule instead.

(max says the same with "above" and refers to min for the class.)

minLength / maxLength — one sentence each placing them in that class:

Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused.

InlineGridColumnSchema.requiredWhen — the trap next door, measured rather than assumed: nothing on the write path reads inlineColumns at all (driver-sql classifies the key presentation, and no consumer outside the renderer exists).

PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own requiredWhen — a transition gate, see Field.requiredWhen — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.

ScriptValidationSchema.condition (packages/spec/src/data/validation.zod.ts):

A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use Field.requiredWhen or a field bound (min / max / maxLength), which judge the write rather than the stored row.

Module docblock (validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules, validateRecord). It renders into content/docs/references/data/validation.mdx.

cross_field needs nothing of its own: the file already states it "shares the exact evaluation path as script", and the new section names it.

Deliverable 3: named, not edited

The card's optional third deliverable was an implementation note in packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text names evaluateValidationRules (and validateRecord) by symbol so a reader can follow it.

Proposed skills tool-choice line (for the follow-up card)

Deliverable 2 of the card is the tool-choice line in the published skills/**. That surface is domain:skills and governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:

Need an invariant — a condition every stored record must satisfy ("discount may never exceed 60") — declare it as a validations[]script rule: it is re-checked against the merged record on every write, so a row that already violates it is refused on any edit until a repairing write lands (frozen, not bricked).
Need a transition condition — something that must hold from a given point onward ("a quote must name a contact by the time it is presented") — use requiredWhen or a field bound (min / max / maxLength): these judge the write, not the stored row, so rows that predate the rule keep passing unrelated edits. "Required when X" reads like an invariant and is not one.

The pin

packages/spec/src/data/validation-boundary-description.test.ts — 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.description and siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.

Reverse verification (expected direction: RED). With the fix committed first, A TRANSITION GATE, not an invariant: was replaced by A conditional requirement: in field.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12 to 45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored with git checkout HEAD -- under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to 294c9e12 and an empty git diff HEAD. No dist rebuild is involved in this ablation: the pin imports ./field.zod as a same-package relative source path, which vitest transforms from src — not through the package exports into dist.

Regenerated artifacts

pnpm --filter @objectstack/spec check:generated --fix reported exactly one stale artifact and regenerated only it (gen:docs):

  • content/docs/references/data/field.mdx
  • content/docs/references/data/object.mdx
  • content/docs/references/data/validation.mdx
  • content/docs/references/system/migration.mdx (the add_field migration op embeds the field schema)

Every changed line in those four files is one of the new sentences — verified by filtering the diff.

Verification record — union run at e8cd5aa2

Heavy runs went through scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.

RunResult
pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0
pnpm --filter @objectstack/spec buildVERDICT command-exit 0, 34/34 declared declaration file(s) present
pnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459); Tests 12294 passed, 1 skipped (12295)
pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0; check:test-typecheck: OK
new pin aloneTest Files 1 passed, Tests 18 passed
engine measurement (scratch, deleted)Tests 12 passed (12) alongside rule-validator.test.ts + record-validator.test.ts, Tests 293 passed (293)
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — no narrowing claimed
pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)

Gate family re-derived on the actual changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths, three-dot against merge base 2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:

  • node scripts/check-dev-prereqs.mjs — "The workspace is not built — 1 unmet precondition, not a list of problems."
  • node scripts/check-test-completeness.mjs (exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt (exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."

Seven gates that read packages/spec/dist went red on the first pass purely on mtime — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src. All seven are green after rebuilding (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples; the last also needed the @objectstack/client-react closure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.

Scope

packages/spec only, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves; packages/objectql is not touched. Per the region split with #13894, this PR writes only the requiredWhen / bounds describe texts in field.zod.ts — the unique line, the autonumber block and RUNTIME_OWNED_FIELD_TYPES are untouched, and that card rebases on this one. A patch changeset for @objectstack/spec is included, so no skip-changeset label.

One side finding, filed unassigned rather than fixed here: #14891 — the script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin in rule-validator.test.ts, while the requiredWhen side's mirror case does.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE

Generated by Claude Code


Generated by Claude Code

…eclaration
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`)
are transition gates; a `validations[]` `script` rule is a true invariant. Both
semantics are deliberate and neither moves here — what was missing is that no
platform surface said so, while "required when X" reads as an invariant.
Measured against the engine before writing a word (12 cases, all green):
`evaluateValidationRules` refuses a `requiredWhen` write only when the merged
record violates AND the pre-write record complied (the ADR-0113 non-regression
branch), `validateRecord` iterates the UPDATE payload so a bound is checked on
the written value only, and `checkPredicate` re-evaluates a `script` rule
against the merged record with no pre-state exemption.
Adds a schema-reachable pin over the contract text, and regenerates the
reference pages the `.describe()` strings feed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx(via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17.mdx(via FieldSchema (symbol, a top-level const))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8be6516df9f696135ac29668afa4083a93e83194 — the merge of head 682b107e4a187b1a23e143b2840011712fa6e801 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

CI on e8cd5aa21 (seat reading, 08:19Z): Test Core (1/6) (job 100569643525, run 33730657230) is the one red of 36 checks — and it is not this PR's failure.

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 180104 ms; case 1 measured the same child at 5899 ms on this runner minutes earlier: expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)

That is #14832 — the os dev hang in an unbuilt workspace, a packages/cli product defect (pm:dispatched to the domain:cli seat; the quarantine PR #14871 under maintainer ruling A is in the merge queue but not on main yet, so there is nothing to port). This PR's diff is packages/spec describe text, one pin, a changeset and four regenerated reference pages; it does not touch packages/cli, and every other shard is green. Failed jobs re-run once (MCP rerun_failed_jobs, requested in the same stroke as this note) — the one re-run the seat's rule allows. Green ⇒ the landing path (trial merge is already clean vs 2263ca4d6, NOT governed 0/8, ACCEPT 5522546337 on #13879). Red again on the same line ⇒ no second re-run: card #13879 goes pm:blocked on #14832 with Unlock-action: re-check PR #14889, and the branch gets origin/main merged in once #14871 lands (a re-run replays the old merge commit).


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):

  • CI on head 682b107e4: 34 checks, 0 red, 0 pending — the Test Core (1/6) hang is quarantined on main (PR test(cli): quarantine the never-read-reader hang case behind #14832 (maintainer ruling A, 2026-09-03) #14871, 392f41089), so the shard that redded the previous head is green here.
  • Trial merge against origin/main7251bad7d: clean (tree 7f9d9c095).
  • Governed-surface read on the final 8-file list: NOT governed (0 of 8 hit the register) — ordinary queue landing applies.
  • Contract review: Clause ② no; seat ACCEPT 5522546337 (the merge commit added only main's own commits, the reviewed diff is unchanged).

⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ pm:blocked on the anchor card with Unlock-action: re-check PR #14889.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 3, 2026 09:05
@os-sam
os-sam enabled auto-merge September 3, 2026 09:05
@os-sam
os-sam added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0169d49Sep 3, 2026
36 checks passed
@os-sam
os-sam deleted the claude/issue-13879-required-when-transition-gate-docblocks branch September 3, 2026 11:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks
Sep 3, 2026
Merged

docs(spec): state the transition-gate vs invariant boundary at each declaration#14889
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#13879

Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so. Field.requiredWhen and the field bounds (min / max / minLength / maxLength) are transition gates; a validations[]script rule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.

The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.

The premise, re-measured against origin/main before a word was written

The card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over evaluateValidationRules / validateRecord (12 cases, all green; the file was deleted before the commit — this PR adds no test to packages/objectql).

CaseEngine behaviourCode path / existing pin
requiredWhen: INSERT born inside the gate, value absentREFUSEDrule-validator.tsevaluateValidationRules, field-rule block; pinned by rule-validator.test.ts "rejects a missing required-when field (insert, predicate TRUE)"
requiredWhen: draft to presented, value absentREFUSED — merged violates, pre-state compliedsame block; pinned by "update that CREATES the violation is rejected"
requiredWhen: clearing the cell while inside the gateREFUSEDsame block; pinned by "update that nulls the field out under a TRUE condition is rejected"
requiredWhen: in-gate move presented to accepted on a row already missing the valuePASSESpreViolated short-circuitsame block (if (preViolated) continue; // legacy rows rest)
requiredWhen: unrelated edit on a row already missing the valuePASSESsame; pinned by "legacy rows rest: a pre-existing violation does not block an unrelated write"
requiredWhen: repairing writePASSESpinned by "a write that FIXES the legacy violation passes"
bound max: write carrying an out-of-bound valueREFUSED (max_value)record-validator.tsvalidateOne, number branch
bound max: unrelated edit while a stored out-of-bound value standsPASSES — the stored value is never re-readvalidateRecord's UPDATE branch iterates Object.entries(data), the payload, not the field map
bound max: repairing writePASSESsame branch
script: unrelated edit while a stored violating value standsREFUSED — frozencheckPredicate over ctx.merged, no pre-state exemption
script: repairing writePASSES — not brickedsame; the merged record no longer violates
script: INSERT that violatesREFUSEDsame

One refinement over the card's wording, and it is load-bearing for the text.requiredWhen is not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like a script rule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason — validateRecord iterates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.

The sentences added, per site

All four sites are .describe() text, so each ships as the JSON Schema description and as the generated reference page — the copy an author actually reads.

FieldSchema.requiredWhen (packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":

A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[]script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules.

min / max — the bare "Minimum value" / "Maximum value" now carry:

Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[]script rule instead.

(max says the same with "above" and refers to min for the class.)

minLength / maxLength — one sentence each placing them in that class:

Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused.

InlineGridColumnSchema.requiredWhen — the trap next door, measured rather than assumed: nothing on the write path reads inlineColumns at all (driver-sql classifies the key presentation, and no consumer outside the renderer exists).

PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own requiredWhen — a transition gate, see Field.requiredWhen — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.

ScriptValidationSchema.condition (packages/spec/src/data/validation.zod.ts):

A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use Field.requiredWhen or a field bound (min / max / maxLength), which judge the write rather than the stored row.

Module docblock (validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules, validateRecord). It renders into content/docs/references/data/validation.mdx.

cross_field needs nothing of its own: the file already states it "shares the exact evaluation path as script", and the new section names it.

Deliverable 3: named, not edited

The card's optional third deliverable was an implementation note in packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text names evaluateValidationRules (and validateRecord) by symbol so a reader can follow it.

Proposed skills tool-choice line (for the follow-up card)

Deliverable 2 of the card is the tool-choice line in the published skills/**. That surface is domain:skills and governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:

Need an invariant — a condition every stored record must satisfy ("discount may never exceed 60") — declare it as a validations[]script rule: it is re-checked against the merged record on every write, so a row that already violates it is refused on any edit until a repairing write lands (frozen, not bricked).
Need a transition condition — something that must hold from a given point onward ("a quote must name a contact by the time it is presented") — use requiredWhen or a field bound (min / max / maxLength): these judge the write, not the stored row, so rows that predate the rule keep passing unrelated edits. "Required when X" reads like an invariant and is not one.

The pin

packages/spec/src/data/validation-boundary-description.test.ts — 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.description and siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.

Reverse verification (expected direction: RED). With the fix committed first, A TRANSITION GATE, not an invariant: was replaced by A conditional requirement: in field.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12 to 45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored with git checkout HEAD -- under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to 294c9e12 and an empty git diff HEAD. No dist rebuild is involved in this ablation: the pin imports ./field.zod as a same-package relative source path, which vitest transforms from src — not through the package exports into dist.

Regenerated artifacts

pnpm --filter @objectstack/spec check:generated --fix reported exactly one stale artifact and regenerated only it (gen:docs):

  • content/docs/references/data/field.mdx
  • content/docs/references/data/object.mdx
  • content/docs/references/data/validation.mdx
  • content/docs/references/system/migration.mdx (the add_field migration op embeds the field schema)

Every changed line in those four files is one of the new sentences — verified by filtering the diff.

Verification record — union run at e8cd5aa2

Heavy runs went through scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.

RunResult
pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0
pnpm --filter @objectstack/spec buildVERDICT command-exit 0, 34/34 declared declaration file(s) present
pnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459); Tests 12294 passed, 1 skipped (12295)
pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0; check:test-typecheck: OK
new pin aloneTest Files 1 passed, Tests 18 passed
engine measurement (scratch, deleted)Tests 12 passed (12) alongside rule-validator.test.ts + record-validator.test.ts, Tests 293 passed (293)
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — no narrowing claimed
pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)

Gate family re-derived on the actual changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths, three-dot against merge base 2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:

  • node scripts/check-dev-prereqs.mjs — "The workspace is not built — 1 unmet precondition, not a list of problems."
  • node scripts/check-test-completeness.mjs (exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt (exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."

Seven gates that read packages/spec/dist went red on the first pass purely on mtime — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src. All seven are green after rebuilding (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples; the last also needed the @objectstack/client-react closure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.

Scope

packages/spec only, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves; packages/objectql is not touched. Per the region split with #13894, this PR writes only the requiredWhen / bounds describe texts in field.zod.ts — the unique line, the autonumber block and RUNTIME_OWNED_FIELD_TYPES are untouched, and that card rebases on this one. A patch changeset for @objectstack/spec is included, so no skip-changeset label.

One side finding, filed unassigned rather than fixed here: #14891 — the script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin in rule-validator.test.ts, while the requiredWhen side's mirror case does.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE

Generated by Claude Code


Generated by Claude Code

…eclaration
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`)
are transition gates; a `validations[]` `script` rule is a true invariant. Both
semantics are deliberate and neither moves here — what was missing is that no
platform surface said so, while "required when X" reads as an invariant.
Measured against the engine before writing a word (12 cases, all green):
`evaluateValidationRules` refuses a `requiredWhen` write only when the merged
record violates AND the pre-write record complied (the ADR-0113 non-regression
branch), `validateRecord` iterates the UPDATE payload so a bound is checked on
the written value only, and `checkPredicate` re-evaluates a `script` rule
against the merged record with no pre-state exemption.
Adds a schema-reachable pin over the contract text, and regenerates the
reference pages the `.describe()` strings feed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx(via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17.mdx(via FieldSchema (symbol, a top-level const))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8be6516df9f696135ac29668afa4083a93e83194 — the merge of head 682b107e4a187b1a23e143b2840011712fa6e801 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

CI on e8cd5aa21 (seat reading, 08:19Z): Test Core (1/6) (job 100569643525, run 33730657230) is the one red of 36 checks — and it is not this PR's failure.

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 180104 ms; case 1 measured the same child at 5899 ms on this runner minutes earlier: expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)

That is #14832 — the os dev hang in an unbuilt workspace, a packages/cli product defect (pm:dispatched to the domain:cli seat; the quarantine PR #14871 under maintainer ruling A is in the merge queue but not on main yet, so there is nothing to port). This PR's diff is packages/spec describe text, one pin, a changeset and four regenerated reference pages; it does not touch packages/cli, and every other shard is green. Failed jobs re-run once (MCP rerun_failed_jobs, requested in the same stroke as this note) — the one re-run the seat's rule allows. Green ⇒ the landing path (trial merge is already clean vs 2263ca4d6, NOT governed 0/8, ACCEPT 5522546337 on #13879). Red again on the same line ⇒ no second re-run: card #13879 goes pm:blocked on #14832 with Unlock-action: re-check PR #14889, and the branch gets origin/main merged in once #14871 lands (a re-run replays the old merge commit).


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):

  • CI on head 682b107e4: 34 checks, 0 red, 0 pending — the Test Core (1/6) hang is quarantined on main (PR test(cli): quarantine the never-read-reader hang case behind #14832 (maintainer ruling A, 2026-09-03) #14871, 392f41089), so the shard that redded the previous head is green here.
  • Trial merge against origin/main7251bad7d: clean (tree 7f9d9c095).
  • Governed-surface read on the final 8-file list: NOT governed (0 of 8 hit the register) — ordinary queue landing applies.
  • Contract review: Clause ② no; seat ACCEPT 5522546337 (the merge commit added only main's own commits, the reviewed diff is unchanged).

⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ pm:blocked on the anchor card with Unlock-action: re-check PR #14889.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 3, 2026 09:05
@os-sam
os-sam enabled auto-merge September 3, 2026 09:05
@os-sam
os-sam added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0169d49Sep 3, 2026
36 checks passed
@os-sam
os-sam deleted the claude/issue-13879-required-when-transition-gate-docblocks branch September 3, 2026 11:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks
Sep 3, 2026
Merged

docs(spec): state the transition-gate vs invariant boundary at each declaration#14889
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#13879

Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so. Field.requiredWhen and the field bounds (min / max / minLength / maxLength) are transition gates; a validations[]script rule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.

The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.

The premise, re-measured against origin/main before a word was written

The card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over evaluateValidationRules / validateRecord (12 cases, all green; the file was deleted before the commit — this PR adds no test to packages/objectql).

CaseEngine behaviourCode path / existing pin
requiredWhen: INSERT born inside the gate, value absentREFUSEDrule-validator.tsevaluateValidationRules, field-rule block; pinned by rule-validator.test.ts "rejects a missing required-when field (insert, predicate TRUE)"
requiredWhen: draft to presented, value absentREFUSED — merged violates, pre-state compliedsame block; pinned by "update that CREATES the violation is rejected"
requiredWhen: clearing the cell while inside the gateREFUSEDsame block; pinned by "update that nulls the field out under a TRUE condition is rejected"
requiredWhen: in-gate move presented to accepted on a row already missing the valuePASSESpreViolated short-circuitsame block (if (preViolated) continue; // legacy rows rest)
requiredWhen: unrelated edit on a row already missing the valuePASSESsame; pinned by "legacy rows rest: a pre-existing violation does not block an unrelated write"
requiredWhen: repairing writePASSESpinned by "a write that FIXES the legacy violation passes"
bound max: write carrying an out-of-bound valueREFUSED (max_value)record-validator.tsvalidateOne, number branch
bound max: unrelated edit while a stored out-of-bound value standsPASSES — the stored value is never re-readvalidateRecord's UPDATE branch iterates Object.entries(data), the payload, not the field map
bound max: repairing writePASSESsame branch
script: unrelated edit while a stored violating value standsREFUSED — frozencheckPredicate over ctx.merged, no pre-state exemption
script: repairing writePASSES — not brickedsame; the merged record no longer violates
script: INSERT that violatesREFUSEDsame

One refinement over the card's wording, and it is load-bearing for the text.requiredWhen is not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like a script rule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason — validateRecord iterates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.

The sentences added, per site

All four sites are .describe() text, so each ships as the JSON Schema description and as the generated reference page — the copy an author actually reads.

FieldSchema.requiredWhen (packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":

A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[]script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules.

min / max — the bare "Minimum value" / "Maximum value" now carry:

Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[]script rule instead.

(max says the same with "above" and refers to min for the class.)

minLength / maxLength — one sentence each placing them in that class:

Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused.

InlineGridColumnSchema.requiredWhen — the trap next door, measured rather than assumed: nothing on the write path reads inlineColumns at all (driver-sql classifies the key presentation, and no consumer outside the renderer exists).

PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own requiredWhen — a transition gate, see Field.requiredWhen — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.

ScriptValidationSchema.condition (packages/spec/src/data/validation.zod.ts):

A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use Field.requiredWhen or a field bound (min / max / maxLength), which judge the write rather than the stored row.

Module docblock (validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules, validateRecord). It renders into content/docs/references/data/validation.mdx.

cross_field needs nothing of its own: the file already states it "shares the exact evaluation path as script", and the new section names it.

Deliverable 3: named, not edited

The card's optional third deliverable was an implementation note in packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text names evaluateValidationRules (and validateRecord) by symbol so a reader can follow it.

Proposed skills tool-choice line (for the follow-up card)

Deliverable 2 of the card is the tool-choice line in the published skills/**. That surface is domain:skills and governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:

Need an invariant — a condition every stored record must satisfy ("discount may never exceed 60") — declare it as a validations[]script rule: it is re-checked against the merged record on every write, so a row that already violates it is refused on any edit until a repairing write lands (frozen, not bricked).
Need a transition condition — something that must hold from a given point onward ("a quote must name a contact by the time it is presented") — use requiredWhen or a field bound (min / max / maxLength): these judge the write, not the stored row, so rows that predate the rule keep passing unrelated edits. "Required when X" reads like an invariant and is not one.

The pin

packages/spec/src/data/validation-boundary-description.test.ts — 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.description and siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.

Reverse verification (expected direction: RED). With the fix committed first, A TRANSITION GATE, not an invariant: was replaced by A conditional requirement: in field.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12 to 45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored with git checkout HEAD -- under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to 294c9e12 and an empty git diff HEAD. No dist rebuild is involved in this ablation: the pin imports ./field.zod as a same-package relative source path, which vitest transforms from src — not through the package exports into dist.

Regenerated artifacts

pnpm --filter @objectstack/spec check:generated --fix reported exactly one stale artifact and regenerated only it (gen:docs):

  • content/docs/references/data/field.mdx
  • content/docs/references/data/object.mdx
  • content/docs/references/data/validation.mdx
  • content/docs/references/system/migration.mdx (the add_field migration op embeds the field schema)

Every changed line in those four files is one of the new sentences — verified by filtering the diff.

Verification record — union run at e8cd5aa2

Heavy runs went through scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.

RunResult
pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0
pnpm --filter @objectstack/spec buildVERDICT command-exit 0, 34/34 declared declaration file(s) present
pnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459); Tests 12294 passed, 1 skipped (12295)
pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0; check:test-typecheck: OK
new pin aloneTest Files 1 passed, Tests 18 passed
engine measurement (scratch, deleted)Tests 12 passed (12) alongside rule-validator.test.ts + record-validator.test.ts, Tests 293 passed (293)
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — no narrowing claimed
pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)

Gate family re-derived on the actual changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths, three-dot against merge base 2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:

  • node scripts/check-dev-prereqs.mjs — "The workspace is not built — 1 unmet precondition, not a list of problems."
  • node scripts/check-test-completeness.mjs (exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt (exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."

Seven gates that read packages/spec/dist went red on the first pass purely on mtime — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src. All seven are green after rebuilding (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples; the last also needed the @objectstack/client-react closure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.

Scope

packages/spec only, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves; packages/objectql is not touched. Per the region split with #13894, this PR writes only the requiredWhen / bounds describe texts in field.zod.ts — the unique line, the autonumber block and RUNTIME_OWNED_FIELD_TYPES are untouched, and that card rebases on this one. A patch changeset for @objectstack/spec is included, so no skip-changeset label.

One side finding, filed unassigned rather than fixed here: #14891 — the script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin in rule-validator.test.ts, while the requiredWhen side's mirror case does.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE

Generated by Claude Code


Generated by Claude Code

…eclaration
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`)
are transition gates; a `validations[]` `script` rule is a true invariant. Both
semantics are deliberate and neither moves here — what was missing is that no
platform surface said so, while "required when X" reads as an invariant.
Measured against the engine before writing a word (12 cases, all green):
`evaluateValidationRules` refuses a `requiredWhen` write only when the merged
record violates AND the pre-write record complied (the ADR-0113 non-regression
branch), `validateRecord` iterates the UPDATE payload so a bound is checked on
the written value only, and `checkPredicate` re-evaluates a `script` rule
against the merged record with no pre-state exemption.
Adds a schema-reachable pin over the contract text, and regenerates the
reference pages the `.describe()` strings feed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx(via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17.mdx(via FieldSchema (symbol, a top-level const))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8be6516df9f696135ac29668afa4083a93e83194 — the merge of head 682b107e4a187b1a23e143b2840011712fa6e801 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

CI on e8cd5aa21 (seat reading, 08:19Z): Test Core (1/6) (job 100569643525, run 33730657230) is the one red of 36 checks — and it is not this PR's failure.

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 180104 ms; case 1 measured the same child at 5899 ms on this runner minutes earlier: expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)

That is #14832 — the os dev hang in an unbuilt workspace, a packages/cli product defect (pm:dispatched to the domain:cli seat; the quarantine PR #14871 under maintainer ruling A is in the merge queue but not on main yet, so there is nothing to port). This PR's diff is packages/spec describe text, one pin, a changeset and four regenerated reference pages; it does not touch packages/cli, and every other shard is green. Failed jobs re-run once (MCP rerun_failed_jobs, requested in the same stroke as this note) — the one re-run the seat's rule allows. Green ⇒ the landing path (trial merge is already clean vs 2263ca4d6, NOT governed 0/8, ACCEPT 5522546337 on #13879). Red again on the same line ⇒ no second re-run: card #13879 goes pm:blocked on #14832 with Unlock-action: re-check PR #14889, and the branch gets origin/main merged in once #14871 lands (a re-run replays the old merge commit).


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):

  • CI on head 682b107e4: 34 checks, 0 red, 0 pending — the Test Core (1/6) hang is quarantined on main (PR test(cli): quarantine the never-read-reader hang case behind #14832 (maintainer ruling A, 2026-09-03) #14871, 392f41089), so the shard that redded the previous head is green here.
  • Trial merge against origin/main7251bad7d: clean (tree 7f9d9c095).
  • Governed-surface read on the final 8-file list: NOT governed (0 of 8 hit the register) — ordinary queue landing applies.
  • Contract review: Clause ② no; seat ACCEPT 5522546337 (the merge commit added only main's own commits, the reviewed diff is unchanged).

⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ pm:blocked on the anchor card with Unlock-action: re-check PR #14889.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 3, 2026 09:05
@os-sam
os-sam enabled auto-merge September 3, 2026 09:05
@os-sam
os-sam added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0169d49Sep 3, 2026
36 checks passed
@os-sam
os-sam deleted the claude/issue-13879-required-when-transition-gate-docblocks branch September 3, 2026 11:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks
Sep 3, 2026
Merged

docs(spec): state the transition-gate vs invariant boundary at each declaration#14889
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#13879

Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so. Field.requiredWhen and the field bounds (min / max / minLength / maxLength) are transition gates; a validations[]script rule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.

The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.

The premise, re-measured against origin/main before a word was written

The card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over evaluateValidationRules / validateRecord (12 cases, all green; the file was deleted before the commit — this PR adds no test to packages/objectql).

CaseEngine behaviourCode path / existing pin
requiredWhen: INSERT born inside the gate, value absentREFUSEDrule-validator.tsevaluateValidationRules, field-rule block; pinned by rule-validator.test.ts "rejects a missing required-when field (insert, predicate TRUE)"
requiredWhen: draft to presented, value absentREFUSED — merged violates, pre-state compliedsame block; pinned by "update that CREATES the violation is rejected"
requiredWhen: clearing the cell while inside the gateREFUSEDsame block; pinned by "update that nulls the field out under a TRUE condition is rejected"
requiredWhen: in-gate move presented to accepted on a row already missing the valuePASSESpreViolated short-circuitsame block (if (preViolated) continue; // legacy rows rest)
requiredWhen: unrelated edit on a row already missing the valuePASSESsame; pinned by "legacy rows rest: a pre-existing violation does not block an unrelated write"
requiredWhen: repairing writePASSESpinned by "a write that FIXES the legacy violation passes"
bound max: write carrying an out-of-bound valueREFUSED (max_value)record-validator.tsvalidateOne, number branch
bound max: unrelated edit while a stored out-of-bound value standsPASSES — the stored value is never re-readvalidateRecord's UPDATE branch iterates Object.entries(data), the payload, not the field map
bound max: repairing writePASSESsame branch
script: unrelated edit while a stored violating value standsREFUSED — frozencheckPredicate over ctx.merged, no pre-state exemption
script: repairing writePASSES — not brickedsame; the merged record no longer violates
script: INSERT that violatesREFUSEDsame

One refinement over the card's wording, and it is load-bearing for the text.requiredWhen is not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like a script rule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason — validateRecord iterates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.

The sentences added, per site

All four sites are .describe() text, so each ships as the JSON Schema description and as the generated reference page — the copy an author actually reads.

FieldSchema.requiredWhen (packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":

A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[]script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules.

min / max — the bare "Minimum value" / "Maximum value" now carry:

Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[]script rule instead.

(max says the same with "above" and refers to min for the class.)

minLength / maxLength — one sentence each placing them in that class:

Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused.

InlineGridColumnSchema.requiredWhen — the trap next door, measured rather than assumed: nothing on the write path reads inlineColumns at all (driver-sql classifies the key presentation, and no consumer outside the renderer exists).

PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own requiredWhen — a transition gate, see Field.requiredWhen — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.

ScriptValidationSchema.condition (packages/spec/src/data/validation.zod.ts):

A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use Field.requiredWhen or a field bound (min / max / maxLength), which judge the write rather than the stored row.

Module docblock (validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules, validateRecord). It renders into content/docs/references/data/validation.mdx.

cross_field needs nothing of its own: the file already states it "shares the exact evaluation path as script", and the new section names it.

Deliverable 3: named, not edited

The card's optional third deliverable was an implementation note in packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text names evaluateValidationRules (and validateRecord) by symbol so a reader can follow it.

Proposed skills tool-choice line (for the follow-up card)

Deliverable 2 of the card is the tool-choice line in the published skills/**. That surface is domain:skills and governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:

Need an invariant — a condition every stored record must satisfy ("discount may never exceed 60") — declare it as a validations[]script rule: it is re-checked against the merged record on every write, so a row that already violates it is refused on any edit until a repairing write lands (frozen, not bricked).
Need a transition condition — something that must hold from a given point onward ("a quote must name a contact by the time it is presented") — use requiredWhen or a field bound (min / max / maxLength): these judge the write, not the stored row, so rows that predate the rule keep passing unrelated edits. "Required when X" reads like an invariant and is not one.

The pin

packages/spec/src/data/validation-boundary-description.test.ts — 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.description and siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.

Reverse verification (expected direction: RED). With the fix committed first, A TRANSITION GATE, not an invariant: was replaced by A conditional requirement: in field.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12 to 45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored with git checkout HEAD -- under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to 294c9e12 and an empty git diff HEAD. No dist rebuild is involved in this ablation: the pin imports ./field.zod as a same-package relative source path, which vitest transforms from src — not through the package exports into dist.

Regenerated artifacts

pnpm --filter @objectstack/spec check:generated --fix reported exactly one stale artifact and regenerated only it (gen:docs):

  • content/docs/references/data/field.mdx
  • content/docs/references/data/object.mdx
  • content/docs/references/data/validation.mdx
  • content/docs/references/system/migration.mdx (the add_field migration op embeds the field schema)

Every changed line in those four files is one of the new sentences — verified by filtering the diff.

Verification record — union run at e8cd5aa2

Heavy runs went through scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.

RunResult
pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0
pnpm --filter @objectstack/spec buildVERDICT command-exit 0, 34/34 declared declaration file(s) present
pnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459); Tests 12294 passed, 1 skipped (12295)
pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0; check:test-typecheck: OK
new pin aloneTest Files 1 passed, Tests 18 passed
engine measurement (scratch, deleted)Tests 12 passed (12) alongside rule-validator.test.ts + record-validator.test.ts, Tests 293 passed (293)
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — no narrowing claimed
pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)

Gate family re-derived on the actual changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths, three-dot against merge base 2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:

  • node scripts/check-dev-prereqs.mjs — "The workspace is not built — 1 unmet precondition, not a list of problems."
  • node scripts/check-test-completeness.mjs (exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt (exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."

Seven gates that read packages/spec/dist went red on the first pass purely on mtime — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src. All seven are green after rebuilding (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples; the last also needed the @objectstack/client-react closure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.

Scope

packages/spec only, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves; packages/objectql is not touched. Per the region split with #13894, this PR writes only the requiredWhen / bounds describe texts in field.zod.ts — the unique line, the autonumber block and RUNTIME_OWNED_FIELD_TYPES are untouched, and that card rebases on this one. A patch changeset for @objectstack/spec is included, so no skip-changeset label.

One side finding, filed unassigned rather than fixed here: #14891 — the script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin in rule-validator.test.ts, while the requiredWhen side's mirror case does.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE

Generated by Claude Code


Generated by Claude Code

…eclaration
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`)
are transition gates; a `validations[]` `script` rule is a true invariant. Both
semantics are deliberate and neither moves here — what was missing is that no
platform surface said so, while "required when X" reads as an invariant.
Measured against the engine before writing a word (12 cases, all green):
`evaluateValidationRules` refuses a `requiredWhen` write only when the merged
record violates AND the pre-write record complied (the ADR-0113 non-regression
branch), `validateRecord` iterates the UPDATE payload so a bound is checked on
the written value only, and `checkPredicate` re-evaluates a `script` rule
against the merged record with no pre-state exemption.
Adds a schema-reachable pin over the contract text, and regenerates the
reference pages the `.describe()` strings feed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx(via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17.mdx(via FieldSchema (symbol, a top-level const))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8be6516df9f696135ac29668afa4083a93e83194 — the merge of head 682b107e4a187b1a23e143b2840011712fa6e801 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

CI on e8cd5aa21 (seat reading, 08:19Z): Test Core (1/6) (job 100569643525, run 33730657230) is the one red of 36 checks — and it is not this PR's failure.

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 180104 ms; case 1 measured the same child at 5899 ms on this runner minutes earlier: expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)

That is #14832 — the os dev hang in an unbuilt workspace, a packages/cli product defect (pm:dispatched to the domain:cli seat; the quarantine PR #14871 under maintainer ruling A is in the merge queue but not on main yet, so there is nothing to port). This PR's diff is packages/spec describe text, one pin, a changeset and four regenerated reference pages; it does not touch packages/cli, and every other shard is green. Failed jobs re-run once (MCP rerun_failed_jobs, requested in the same stroke as this note) — the one re-run the seat's rule allows. Green ⇒ the landing path (trial merge is already clean vs 2263ca4d6, NOT governed 0/8, ACCEPT 5522546337 on #13879). Red again on the same line ⇒ no second re-run: card #13879 goes pm:blocked on #14832 with Unlock-action: re-check PR #14889, and the branch gets origin/main merged in once #14871 lands (a re-run replays the old merge commit).


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):

  • CI on head 682b107e4: 34 checks, 0 red, 0 pending — the Test Core (1/6) hang is quarantined on main (PR test(cli): quarantine the never-read-reader hang case behind #14832 (maintainer ruling A, 2026-09-03) #14871, 392f41089), so the shard that redded the previous head is green here.
  • Trial merge against origin/main7251bad7d: clean (tree 7f9d9c095).
  • Governed-surface read on the final 8-file list: NOT governed (0 of 8 hit the register) — ordinary queue landing applies.
  • Contract review: Clause ② no; seat ACCEPT 5522546337 (the merge commit added only main's own commits, the reviewed diff is unchanged).

⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ pm:blocked on the anchor card with Unlock-action: re-check PR #14889.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 3, 2026 09:05
@os-sam
os-sam enabled auto-merge September 3, 2026 09:05
@os-sam
os-sam added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0169d49Sep 3, 2026
36 checks passed
@os-sam
os-sam deleted the claude/issue-13879-required-when-transition-gate-docblocks branch September 3, 2026 11:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

docs(spec): state the transition-gate vs invariant boundary at each declaration - #14889

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks
Sep 3, 2026
Merged

docs(spec): state the transition-gate vs invariant boundary at each declaration#14889
os-sam merged 2 commits into
mainfrom
claude/issue-13879-required-when-transition-gate-docblocks

Conversation

@claude

@claudeclaudeBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes#13879

Two write-time mechanisms in the spec look interchangeable and are not, and nothing on the platform said so. Field.requiredWhen and the field bounds (min / max / minLength / maxLength) are transition gates; a validations[]script rule is a true invariant. Both semantics are deliberate — the card's fence is explicit that neither may move — and this PR changes neither. What it adds is the statement of the boundary, in the spec's own contract text, at each declaration, with each half naming the other tool.

The gap is not cosmetic. "Required when X" reads to a human, and to an AI metadata author, as an invariant. Downstream (hotcrm#1069 / hotcrm#1017) three rules written in prose as invariants were all implemented with the gate tool, with nothing anywhere to signal the difference.

The premise, re-measured against origin/main before a word was written

The card's hotcrm measurements are a double-driver reading of an application. Every case was re-measured here against the engine itself, with a scratch vitest file over evaluateValidationRules / validateRecord (12 cases, all green; the file was deleted before the commit — this PR adds no test to packages/objectql).

CaseEngine behaviourCode path / existing pin
requiredWhen: INSERT born inside the gate, value absentREFUSEDrule-validator.tsevaluateValidationRules, field-rule block; pinned by rule-validator.test.ts "rejects a missing required-when field (insert, predicate TRUE)"
requiredWhen: draft to presented, value absentREFUSED — merged violates, pre-state compliedsame block; pinned by "update that CREATES the violation is rejected"
requiredWhen: clearing the cell while inside the gateREFUSEDsame block; pinned by "update that nulls the field out under a TRUE condition is rejected"
requiredWhen: in-gate move presented to accepted on a row already missing the valuePASSESpreViolated short-circuitsame block (if (preViolated) continue; // legacy rows rest)
requiredWhen: unrelated edit on a row already missing the valuePASSESsame; pinned by "legacy rows rest: a pre-existing violation does not block an unrelated write"
requiredWhen: repairing writePASSESpinned by "a write that FIXES the legacy violation passes"
bound max: write carrying an out-of-bound valueREFUSED (max_value)record-validator.tsvalidateOne, number branch
bound max: unrelated edit while a stored out-of-bound value standsPASSES — the stored value is never re-readvalidateRecord's UPDATE branch iterates Object.entries(data), the payload, not the field map
bound max: repairing writePASSESsame branch
script: unrelated edit while a stored violating value standsREFUSED — frozencheckPredicate over ctx.merged, no pre-state exemption
script: repairing writePASSES — not brickedsame; the merged record no longer violates
script: INSERT that violatesREFUSEDsame

One refinement over the card's wording, and it is load-bearing for the text.requiredWhen is not "evaluated on the written value only" — it is evaluated against the merged record on every write, exactly like a script rule. What makes it a transition gate is the ADR-0113 non-regression branch beside it: the write is refused only when the merged record violates and the pre-write record complied. The bounds are the "written value only" class, for a different reason — validateRecord iterates the UPDATE payload. The docblocks state each mechanism as the code implements it, not as one shared sentence.

The sentences added, per site

All four sites are .describe() text, so each ships as the JSON Schema description and as the generated reference page — the copy an author actually reads.

FieldSchema.requiredWhen (packages/spec/src/data/field.zod.ts) — appended after "field is required when TRUE":

A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[]script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules.

min / max — the bare "Minimum value" / "Maximum value" now carry:

Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[]script rule instead.

(max says the same with "above" and refers to min for the class.)

minLength / maxLength — one sentence each placing them in that class:

Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused.

InlineGridColumnSchema.requiredWhen — the trap next door, measured rather than assumed: nothing on the write path reads inlineColumns at all (driver-sql classifies the key presentation, and no consumer outside the renderer exists).

PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own requiredWhen — a transition gate, see Field.requiredWhen — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.

ScriptValidationSchema.condition (packages/spec/src/data/validation.zod.ts):

A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use Field.requiredWhen or a field bound (min / max / maxLength), which judge the write rather than the stored row.

Module docblock (validation.zod.ts) — a new "Transition gate vs invariant — which tool enforces what" section carrying the whole boundary in one place, the choice with each side's price, and the implementing symbols (evaluateValidationRules, validateRecord). It renders into content/docs/references/data/validation.mdx.

cross_field needs nothing of its own: the file already states it "shares the exact evaluation path as script", and the new section names it.

Deliverable 3: named, not edited

The card's optional third deliverable was an implementation note in packages/objectql/src/validation/rule-validator.ts. PR #14801 is open on that file, so it is left alone; the spec text names evaluateValidationRules (and validateRecord) by symbol so a reader can follow it.

Proposed skills tool-choice line (for the follow-up card)

Deliverable 2 of the card is the tool-choice line in the published skills/**. That surface is domain:skills and governed, so it is not in this PR — it goes to that lane as a follow-up card. Proposed wording, for an app author to read before choosing a tool:

Need an invariant — a condition every stored record must satisfy ("discount may never exceed 60") — declare it as a validations[]script rule: it is re-checked against the merged record on every write, so a row that already violates it is refused on any edit until a repairing write lands (frozen, not bricked).
Need a transition condition — something that must hold from a given point onward ("a quote must name a contact by the time it is presented") — use requiredWhen or a field bound (min / max / maxLength): these judge the write, not the stored row, so rows that predate the rule keep passing unrelated edits. "Required when X" reads like an invariant and is not one.

The pin

packages/spec/src/data/validation-boundary-description.test.ts — 18 assertions reading the text through the schema (FieldSchema.shape.requiredWhen.description and siblings), never by grepping the source: a source grep would pass on a sentence that reaches neither the JSON Schema nor the reference page. It asserts the mechanism clauses, both directions of each gate's behaviour, and the cross-reference in both directions — plus two controls, that the gate text keeps saying "not an invariant" and the invariant text keeps saying "not a transition gate", which is what a later edit tidying the two into one sentence would break.

Reverse verification (expected direction: RED). With the fix committed first, A TRANSITION GATE, not an invariant: was replaced by A conditional requirement: in field.zod.ts. Mutation proved on disk by grep counts anchored on both texts (injected = 1, removed text remaining = 0) and by blob hash (294c9e12 to 45e5bcb6); the pin went RED with exactly 2 failures (the class assertion and the control), 16 still green. Restored with git checkout HEAD -- under an EXIT/INT/TERM trap on an absolute path, proved by blob hash back to 294c9e12 and an empty git diff HEAD. No dist rebuild is involved in this ablation: the pin imports ./field.zod as a same-package relative source path, which vitest transforms from src — not through the package exports into dist.

Regenerated artifacts

pnpm --filter @objectstack/spec check:generated --fix reported exactly one stale artifact and regenerated only it (gen:docs):

  • content/docs/references/data/field.mdx
  • content/docs/references/data/object.mdx
  • content/docs/references/data/validation.mdx
  • content/docs/references/system/migration.mdx (the add_field migration op embeds the field schema)

Every changed line in those four files is one of the new sentences — verified by filtering the diff.

Verification record — union run at e8cd5aa2

Heavy runs went through scripts/pm/os-verify-lock.sh; verdicts quoted from the gate's own line, exit codes captured before any pipe.

RunResult
pnpm --filter '@objectstack/objectql^...' buildVERDICT command-exit 0
pnpm --filter @objectstack/spec buildVERDICT command-exit 0, 34/34 declared declaration file(s) present
pnpm --filter @objectstack/spec testTest Files 458 passed, 1 skipped (459); Tests 12294 passed, 1 skipped (12295)
pnpm --filter @objectstack/spec typecheckVERDICT command-exit 0; check:test-typecheck: OK
new pin aloneTest Files 1 passed, Tests 18 passed
engine measurement (scratch, deleted)Tests 12 passed (12) alongside rule-validator.test.ts + record-validator.test.ts, Tests 293 passed (293)
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — no narrowing claimed
pnpm check:nul-bytesOK (scanned 8074 text file(s) ... no raw ASCII control bytes)

Gate family re-derived on the actual changed paths with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (8 paths, three-dot against merge base 2263ca4d6): 72 commands, 68 green, 0 red, 4 NOT MEASURED. The four are all "the workspace is not built", not findings:

  • node scripts/check-dev-prereqs.mjs — "The workspace is not built — 1 unmet precondition, not a list of problems."
  • node scripts/check-test-completeness.mjs (exit 3) — its own text: "running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt (exit 3) — "--re-measure cannot run ... NOT a pass and NOT a finding."

Seven gates that read packages/spec/dist went red on the first pass purely on mtime — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src. All seven are green after rebuilding (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples; the last also needed the @objectstack/client-react closure, after which: "256 prose examples type-check across 3 surface(s)"). CI is the final reading.

Scope

packages/spec only, plus the generated reference pages. No schema, accepted key set, validator or runtime behaviour moves; packages/objectql is not touched. Per the region split with #13894, this PR writes only the requiredWhen / bounds describe texts in field.zod.ts — the unique line, the autonumber block and RUNTIME_OWNED_FIELD_TYPES are untouched, and that card rebases on this one. A patch changeset for @objectstack/spec is included, so no skip-changeset label.

One side finding, filed unassigned rather than fixed here: #14891 — the script-rule behaviour this PR now states as contract (a stored violation refused on an unrelated edit) has no pin in rule-validator.test.ts, while the requiredWhen side's mirror case does.

🤖 Generated with Claude Code

https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE

Generated by Claude Code


Generated by Claude Code

…eclaration
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`)
are transition gates; a `validations[]` `script` rule is a true invariant. Both
semantics are deliberate and neither moves here — what was missing is that no
platform surface said so, while "required when X" reads as an invariant.
Measured against the engine before writing a word (12 cases, all green):
`evaluateValidationRules` refuses a `requiredWhen` write only when the merged
record violates AND the pre-write record complied (the ADR-0113 non-regression
branch), `validateRecord` iterates the UPDATE payload so a bound is checked on
the written value only, and `checkPredicate` re-evaluates a `script` rule
against the merged record with no pre-state exemption.
Adds a schema-reachable pin over the contract text, and regenerates the
reference pages the `.describe()` strings feed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx(via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17.mdx(via FieldSchema (symbol, a top-level const))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 128 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8be6516df9f696135ac29668afa4083a93e83194 — the merge of head 682b107e4a187b1a23e143b2840011712fa6e801 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 8be6516df9f696135ac29668afa4083a93e83194 && git checkout 8be6516df9f696135ac29668afa4083a93e83194
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 682b107e4a187b1a23e143b2840011712fa6e801 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 682b107e4a187b1a23e143b2840011712fa6e801
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-samClaude

Copy link
Copy Markdown
Collaborator

CI on e8cd5aa21 (seat reading, 08:19Z): Test Core (1/6) (job 100569643525, run 33730657230) is the one red of 36 checks — and it is not this PR's failure.

FAIL integration test/run-dev-unbuilt-workspace.e2e.test.ts > the mirror direction: a reader that is never coming back > gives up and exits instead of waiting forever
AssertionError: the harness SIGKILLed the child — it was still alive at the ceiling. cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design); this child ran 180104 ms; case 1 measured the same child at 5899 ms on this runner minutes earlier: expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)

That is #14832 — the os dev hang in an unbuilt workspace, a packages/cli product defect (pm:dispatched to the domain:cli seat; the quarantine PR #14871 under maintainer ruling A is in the merge queue but not on main yet, so there is nothing to port). This PR's diff is packages/spec describe text, one pin, a changeset and four regenerated reference pages; it does not touch packages/cli, and every other shard is green. Failed jobs re-run once (MCP rerun_failed_jobs, requested in the same stroke as this note) — the one re-run the seat's rule allows. Green ⇒ the landing path (trial merge is already clean vs 2263ca4d6, NOT governed 0/8, ACCEPT 5522546337 on #13879). Red again on the same line ⇒ no second re-run: card #13879 goes pm:blocked on #14832 with Unlock-action: re-check PR #14889, and the branch gets origin/main merged in once #14871 lands (a re-run replays the old merge commit).


Generated by Claude Code

@os-samClaude

Copy link
Copy Markdown
Collaborator

Landing provenance (seat re-seated 09:05Z at the maintainer's instruction to drive the finished work to merge):

  • CI on head 682b107e4: 34 checks, 0 red, 0 pending — the Test Core (1/6) hang is quarantined on main (PR test(cli): quarantine the never-read-reader hang case behind #14832 (maintainer ruling A, 2026-09-03) #14871, 392f41089), so the shard that redded the previous head is green here.
  • Trial merge against origin/main7251bad7d: clean (tree 7f9d9c095).
  • Governed-surface read on the final 8-file list: NOT governed (0 of 8 hit the register) — ordinary queue landing applies.
  • Contract review: Clause ② no; seat ACCEPT 5522546337 (the merge commit added only main's own commits, the reviewed diff is unchanged).

⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ pm:blocked on the anchor card with Unlock-action: re-check PR #14889.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 3, 2026 09:05
@os-sam
os-sam enabled auto-merge September 3, 2026 09:05
@os-sam
os-sam added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 0169d49Sep 3, 2026
36 checks passed
@os-sam
os-sam deleted the claude/issue-13879-required-when-transition-gate-docblocks branch September 3, 2026 11:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude