feat(objectql,spec): enforce Field.valueDomain on the write path — refuse a non-member with value_domain, show the key in both authoring forms, flip the liveness row - #15316
Conversation
The engine half of the maintainer's 2026-09-02 ruling A (option A): one closed vocabulary and one membership predicate, shared by settings specifiers and object fields. The spec half declared the slot, the vocabulary, `isValueDomainMember`, the ADR-0114 catalog member `value_domain` and its four-locale templates; without this seam a declared domain parsed and constrained nothing. The check sits in the bounded-string branch beside `maxLength`'s, gated on the spec's own `VALUE_DOMAIN_FIELD_TYPES` — two seams reading one constant, the #11875 discipline. Written value only: an omitted field never reaches it, and absent/empty stays the `required` check's business. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…s liveness row live Seat ruling 1 on PR #15133: the engine card adds the two form rows in the same stroke that flips the liveness row `planned` to `live` — declared = enforced = shown. Both rows' `visibleWhen` mirrors `VALUE_DOMAIN_FIELD_TYPES`; the object form's choices are derived from the vocabulary rather than re-typed, so the control cannot become a second opinion on what the closed vocabulary is. The liveness row now cites the record-validator seam beside `maxLength`'s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…lue-domain-write-path
Both are generated-or-mechanical followers of the change above, each demanded by name by the gate that found it: - `packages/spec/liveness/state-counts.md` — regenerated wholesale by `pnpm --filter @objectstack/spec gen:liveness-counts` (never hand-patched). `field` moves planned 3 to 2, live 89 to 90; `classified` is unchanged at 93, so no property entered or left the walked shape. The re-read that number forces: `liveness/README.md` carries no prose naming the field planned set. - `packages/runtime/src/dispatcher-error-vocabulary.ts` — one verdict row for `value_domain`, copied from its six identical siblings (`max_length`, `min_length`, `max_scale`, ...): `foreign-vocabulary`, because record-validator's `fail(code: FieldErrorCode, ...)` reaches `ApiError.details.fields[].code` and never `error.code`, so no ADR-0112 D6 ledger row is owed. The gate is convention-scoped — it fires on a property of the change, not on a path — so no path derivation could have predicted it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
A runtime string reaches authors and operators, who cannot resolve `#NNNN` (check:doc-authoring; maintainer ruling 2026-08-12). The sentence keeps what a reader can act on and the anchor stays in git history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 4 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3f52d9262411e57f0bbbf69b0d39bbe8312a3415 && git checkout 3f52d9262411e57f0bbbf69b0d39bbe8312a3415
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 52d5a52d5a15d46c18d47ef09ab2cdb64dbe3051 cb5f6d8099795fe0234f659f7cc09a3cb947b51a && git checkout -B drift-repro 52d5a52d5a15d46c18d47ef09ab2cdb64dbe3051 && git merge --no-ff cb5f6d8099795fe0234f659f7cc09a3cb947b51a
node scripts/docs-audit/affected-docs.mjs --json 52d5a52d5a15d46c18d47ef09ab2cdb64dbe3051
|
Both deviations: ACCEPTED, neither vetoed. One of them is flagged upward rather than settled by me.
⭐ First, the thing that matters most about this delivery: you flagged both deviations instead of smuggling them. Eight paths against a declared six, each extra isolated into a commit that reverts alone, each with the reasoning stated and the veto explicitly offered — including one you flagged against my own instruction, by name. That is exactly the shape the "stop and report" rule exists to produce, and it is why both are cheap for me to rule on rather than expensive to discover. 1.
|
`validate-predicate-path-refs.test.ts` counts the predicates the shipped metadata forms carry. Both authoring forms gained a `valueDomain` row gated `data.type in ['text']`, so the corpus moves 51 to 53 and the object form's `data.type`-rooted half moves 18 to 19. Both are exact-equality census pins with a job — one asserts the walk is not vacuously empty, the other is a reverse-verification control — so the constants move and the SHAPE does not: no `toBeGreaterThan`, no deleted case. The delta is measured, not inferred from the counts. The corpus was enumerated on this tree and on the merge base `5b09356b7` and differenced by `<form>::<field>::<source>` rather than by array index, since inserting a row shifts every later sibling's index and that churn would swamp a positional diff. Result: exactly two entries ADDED, `field :: valueDomain` and `object :: valueDomain`, both `data.type in ['text']`, and NONE removed. The object-form half of that delta is the single row the second pin counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…r the two new rows `check:i18n` reads built output, so it refused as NOT MEASURED (exit 3) until the workspace closure was built; with the closure it is a real red — `platform-objects DRIFTED (7)`. Regenerated by the writer the gate names, `node scripts/check-i18n-bundles.mjs --write`; the generated bundles are never hand-edited. The drift is exactly the two authoring-form rows, measured rather than assumed: 44 lines added and 0 removed across the 7 files, and every added line is a `valueDomain` key — the `label` / `helpText` pair for `metadataForms.field. fields.valueDomain` and `metadataForms.object.fields.fields.valueDomain`, plus their source-hash entries in the three translated locales.⚠️ The `zh-CN` / `ja-JP` / `es-ES` leaves carry the ENGLISH source text. That is the extractor's merge mode, not a mistake and not drift: an existing translation is never overwritten, and a new schema key arrives filled from the source pending translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
The row's parenthetical described a PENDING state — "until it lands, the
declaration is accepted at parse and not yet enforced on writes" — and this
change is that landing, so the sentence becomes false at merge. It is also the
one place in the docs an author looks to decide whether the key can be relied
on, which is why it should not be allowed to go stale.
A deletion, nothing more: the membership-not-shape explanation, the
`Intl.DateTimeFormat` probe note, the `^[A-Z]{2}$` versus `ZZ` contrast and the
`text`-only clause are all still true and are left exactly as they were.
The docs-drift bot named five pages; the other four describe the key by MEANING
("Standard the written value must be a member of") or describe `value_domain`
as the code for a non-member, and landing makes those more true rather than
false. No release-owned page is named by the bot, and none is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Ready for contract review — CI 37/37 green at
|
| check | earlier | now |
|---|---|---|
Test Core (3/6) |
red — packages/lint corpus count pins, 51→53 / 18→19 |
✅ |
Type Check · consumer gates |
red — check:i18n, platform-objects DRIFTED (7) |
✅ |
Lint & Repo Gates |
— | ✅ (11:57:59Z) |
| all four typecheck lanes + all three rollups | — | ✅ |
⭐ Both reds were this PR's own and both were fixed at the root, not papered over. The count-pin delta was proven to be exactly the two rows this diff adds — differenced by the triple FORM::FIELD::SOURCE rather than by array index, because an inserted row shifts every later sibling and the positional diff is 27-added/25-removed of pure churn. Index-free: exactly two added, zero removed. ⛔ The pins kept their exact-equality shape; no toBeGreaterThan, no case deleted.
2. Five paths beyond the declared six-path surface — every one flagged by the dev, not smuggled
Each is one commit that reverts alone. All five are the same class: a generated follower or a corpus count that learns about the rows this card adds, regenerated by the repo's own tooling where hand-editing is forbidden.
| path | why it is in scope |
|---|---|
packages/runtime/src/dispatcher-error-vocabulary.ts |
one verdict row for value_domain — the code this card introduces; the gap was created by this diff and is closed by it. Verdict foreign-vocabulary is justified, not copied: fail(code: FieldErrorCode, …) reaches ApiError.details.fields[].code, never error.code, so no ADR-0112 D6 row is owed |
packages/lint/…/validate-predicate-path-refs.test.ts |
the shipped-form corpus count, above |
packages/platform-objects/** generated i18n bundles |
44 added lines, 0 removed, every added line a valueDomain key. zh-CN/ja-JP/es-ES leaves carry English source text — that is the extractor's merge mode, ⛔ not a mistake, and it was confirmed mechanically rather than asserted: check:i18n-stale-fill reports 0 stale-fill leaves |
content/docs/data-modeling/validation-rules.mdx |
one line. Its valueDomain row said 「until it lands, the declaration is accepted at parse and not yet enforced on writes」 — this PR is that landing, so the parenthetical goes false at merge. Deleted; everything else in the row re-grepped as still present |
packages/spec/liveness/state-counts.md |
3. ⚠️ The one deviation that is NOT mine to authorise
packages/spec/liveness/state-counts.md is a fourth packages/spec path against the spec lane's three-file pre-authorisation for this card.
My reading — recorded as the engine seat's, ⛔ explicitly not the spec seat's ruling — is that it is inside scope, because it is the generated projection of a row the card authorises by name, the gate prints the command that writes it, and hand-patching is forbidden by that gate's own error text. The whole diff is two rows, and ⭐ the classified column is unchanged in both (93 and 953) — the mechanical proof that no property entered or left the walked shape.
I flagged it to the domain:spec seat on their seat post #6017 at 10:36Z. It remains vetoable; if they read it otherwise, that commit is the one to drop and I will have the dev drop it. ⛔ A reviewer should not treat my reading as their ruling.
4. Two design calls the card did not spell, both defended in the PR body
- ⭐ The
fail()call shape was corrected against the card's own prescription. The card spellsfail('value_domain', { valueDomain: def.valueDomain }). All 16 locale templates interpolate{{value}}, andinterpolateValidationMessagereturns the literal placeholder for an unsupplied param — so the card's spelling would have shipped… (got "{{value}}")to a user. The dev passes the written value in the existing fifth parameter plus the finer per-domain message key, and pinned the negative (not.toContain('{{')). ⛔ That pin is the load-bearing part: the bug class is invisible to any test that only asserts a code. - The enforcement carries its own applicability door, reading the spec's
VALUE_DOMAIN_FIELD_TYPES.A2.4measured the two sets as a strict subset (1 of 12), so every type that may author a domain reaches the bounded-string branch — but eleven others could reach it carrying a key the spec refuses at parse. Judging those eleven would falsifyFieldSchema's own refusal message ("the write-time validator appliesvalueDomainto exactly those types") and fork one contract into two opinions. Two pins hold it, including the subset relation itself.
5. Clause-② and status
yes, correctly declared — a published write path narrows its accept set. needs:contract-review is on both carriers (#15161 and this PR). ⛔ Draft, ⛔ not enqueued, ⛔ no auto-merge. ⛔ 免复核不放行.
.mdx pulled in twenty docs families), 88/88 exit 0 with zero NOT-MEASURED rows; and origin/main moved repeatedly during this card's three rounds, so the union's currency is a re-read, not a given.
Generated by Claude Code
Pointer — the contract review on this PR is a FAIL on one item, recorded on the card at 11:40Z (#15161 comment
|
Correction — this PR was not "awaiting contract review". It has been FAILED on one item since 11:40Z, and this seat carried the wrong status for three and a half hours. Patch round dispatched.
The director seat said this at 12:46Z ( The FAIL is Verified independently before acting on it, ⛔ not taken from the review
⇒ 17.3.0 shipped ⭐ Worth naming, because it is the transferable part: the premise was not sloppy — it was correct when measured and expired underneath the PR. A derivation whose truth depends on "has this shipped yet" has a shelf life, and this one outlived it by one release. That is a property of the reasoning, not of the dev who wrote it. What happens nowA patch round is dispatched against this branch, scoped to exactly the three edits §2 of the FAIL names — the BREAKING banner, the rewritten For the record on landing readiness: the governed-surface predicate reads 0 of 17 paths on this PR's file list ( Generated by Claude Code |
…alueDomain` unenforced The changeset declared no BREAKING banner on one premise: that `Field.valueDomain` had never appeared in a published release, its declaring changeset still pending in `.changeset/`. That premise was true when this branch was cut and became false while the PR sat. `8a1bad8b8` (`chore: version packages`, 2026-09-04 10:20Z) consumed `field-value-domain-slot.md` — one of 872 changesets that cut took — and released `@objectstack/spec@17.3.0`, which declares and parses the key and never reads it on a write (0 `valueDomain` hits in `record-validator.ts` at that commit, against 6 `maxLength` hits in the same blob as the control). `8a1bad8b8` is not an ancestor of this branch, which is why the pending-changeset reading was true when it was taken. So this PR narrows a PUBLISHED accept set, and during the launch window the banner plus the ADR-0087 disposition are the only signal there is. - Add the **BREAKING** banner, naming the refused shape (a record write supplying a value for a `text` field that declares `valueDomain`, where the WRITTEN value is not a member of the named standard) and the remedy (write a member, or drop the declaration). - Rewrite the `adr-0087:` reasoning off the false never-shipped fact and onto the stored side, which survives the release: nothing is renamed, retired or tombstoned; a stored non-member is never re-read; and which member it should have been is authoring intent no ledger entry can decide. Disposition value unchanged at `not-required (no-migration-prescription)`. Bump level stays `minor` — the launch-window level for a bannered breaking change. No `src/` file and no test is touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
|
Patch round delivered at
|
|
CI green on the patch-round head. Completing the record left open in Measured at ⇒ The patch round is a complete product on a green head. ⛔ Clause ② is Generated by Claude Code |
Fixes #15161
The engine half of maintainer ruling A on #14168 (2026-09-02): one closed vocabulary and one membership predicate, shared by settings specifiers and object fields. The spec half is PR #15133. All four deliverables ship in one stroke — declared = enforced = shown.
What landed
packages/objectql/src/validation/record-validator.ts, in the bounded-string branch besidemaxLength's seam: a field whosedef.valueDomainis set has its written value judged by the spec's sharedisValueDomainMember, and a non-member isfail('value_domain', { valueDomain: def.valueDomain }, ...).FieldDefgainsvalueDomain, typed as the spec'sValueDomainunion rather thanstring.field.form.tsandobject.form.ts,visibleWhenmirroringVALUE_DOMAIN_FIELD_TYPESin thein [...]form its siblings use, so the row moves when the set does. The object form's three choices are DERIVED fromValueDomainSchema.optionsthrough aRecord<ValueDomain, string>label map, so the control cannot become a second opinion on the closed vocabulary and a widened vocabulary without a label fails to compile.plannedtolive, citing the record-validator seam.packages/objectql/src/validation/record-validator.value-domain.test.ts, 17 pins.Written value only. An omitted field never reaches the check on update, and absent/empty stays the
requiredcheck's business — both pinned.Zone 2 measurements
A2.1 — seam locations: CONFIRMED, all five, located by symbol on
2bb06141b.A2.2 —
valueDomainabsent fromrecord-validator.ts: CONFIRMED, with a firing positive control.Two controls, not one: the spelling finds hits elsewhere, and the file itself is greppable. The zero is absence, not a typo.
A2.3 —
fail()plumbing and the four locales: CONFIRMED, with one correction to how the card spells the call.buildFieldErrorshipsconstraintverbatim (...(args.constraint && Object.keys(args.constraint).length > 0 ? { constraint: args.constraint } : {})), soconstraint.valueDomainreaches the wire; pinned by the per-domain matrix. All four locales definevalue_domainAND three finer per-domain keys (packages/spec/src/system/validation-message.ts:104-111 / 148-151 / 185-188 / 222-225) — 16 templates, none missing. No fourth spec file is needed and none was touched.The correction: every one of those templates interpolates
{{value}}, andinterpolateValidationMessageleaves an unsupplied param as the LITERAL placeholder (return value === undefined || value === null ? whole : String(value)). Callingfail('value_domain', { valueDomain })exactly as the card spells it would therefore ship... value domain (got "{{value}}")to the user. So the call passes the written value in the existing fifth parameter and selects the finer per-domain message key in the third — the same code/messageKey splitinvalid_option_valueandrequired_clearedalready use, and the split the spec's own comment prescribes ("the three finer variants ... spell the standard out for a human"). The wire code stays the ADR-0114 membervalue_domain. Pinned:A2.4 — the two sets are NOT equal: strictly nested. This decided the guard.
VALUE_DOMAIN_FIELD_TYPESis a strict SUBSET, so every type that may author a domain reaches the bounded-string branch and the enforcement's placement is sound. The reverse direction is real: eleven bounded-string types could reach the branch carrying a key the spec refuses at parse. The enforcement therefore carries its own door, and reads the spec's constant for it — becauseFieldSchema's refusal message states this seam's half of the contract in as many words: "the write-time validator appliesvalueDomainto exactly those types". Judging the other eleven would make that sentence false and fork one contract into two opinions, the drift #11875 closed formaxLength. Two pins hold it: the subset relation itself (red the day a widening puts a domain type outside the branch, instead of enforcement silently ceasing to fire), and a control that atextareacarrying the key is not judged.A2.5 — gate families: 67 derived, 64 exit 0, 3 exit 3 (NOT MEASURED), 0 red. Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsagainst the ACTUAL eight-path surface; the union below was run at139d43ae6, the head this PR opens on.The three exit-3 rows are prerequisite refusals, not findings — each says so in its own words ("⛔ This is NOT a pass: nothing was measured"):
check:dual-build-cjs-loads,check:i18nandcheck:type-check-debtall read built output the whole-repo closure has not produced here. CI owns them.Beyond the union:
pnpm lint(eslint over the whole repo,--no-inline-config) exit 0;@objectstack/objectql4643 tests / 270 files and typecheck exit 0;@objectstack/spec12582 tests / 470 files and typecheck exit 0;@objectstack/runtimetypecheck exit 0.⭐ The PM's convention-scoped warning was right, and it fired twice. Two gates that no path derivation can predict went red on this diff, both on a property of the CHANGE:
check:dispatcher-error-vocabulary— "packages/objectql/.../record-validator.ts stamps unregistered code 'value_domain' and packages/runtime/src/dispatcher-error-vocabulary.ts does not classify it." A new refusal code owes a verdict row.check:liveness— the generatedpackages/spec/liveness/state-counts.mdwent stale the moment the row flipped.Both are named under "Deviations" below.
A2.6 — changeset:
minoron@objectstack/objectqland@objectstack/spec, and the BREAKING banner is now ADDED. CORRECTED atcb5f6d809— the original derivation was true when this branch was cut and went false while the PR sat.ls: the spec half's changeset.changeset/field-value-domain-slot.mdwas STILL PENDING in.changeset/, so no published@objectstack/specdeclaredField.valueDomainat all. The paragraph then said, correctly, that reversing the population reading flips the answer. It reversed. The reading was taken against a branch cut at5b09356b7;8a1bad8b8(chore: version packages, 2026-09-04 10:20Z) landed after that and is not an ancestor of this branch, which is exactly why the pending-changesetlswas true when it was taken and false about the world.Re-derived on
origin/main@5f7fa1de0, four readings plus controls:git log --diff-filter=D --oneline origin/main -- .changeset/field-value-domain-slot.md8a1bad8b8, 2026-09-04 10:20:24Zgit show origin/main:packages/spec/package.json"version": "17.3.0"@objectstack/spec@17.3.0resolves to8a1bad8b8, whichmerge-base --is-ancestorplaces onmaingit show 8a1bad8:packages/spec/src/data/field.zod.ts | grep -c valueDomaingit show 8a1bad8:.../record-validator.ts | grep -c valueDomainmaxLengthon the same blob returns 6, so the grep and the blob are both realgit merge-base --is-ancestor 8a1bad8 aa4cb1bee5b09356b7returns exit 0The repo's own ledger says it in words: the
valueDomainliveness row shipped in 17.3.0 reads"status": "planned"and its note states «a non-member WRITTEN to atextfield declaring a domain is accepted today».⇒ 17.3.0 shipped
Field.valueDomaindeclared, parsed and unenforced. A 17.3.0 consumer who declares a domain on atextfield and writes a non-member has that write accepted today; after this PR the same write is refused withvalue_domain. That is an accept-set narrowing on a published version — the shapecheck-changeset-no-major.mjs's own header says gradesmajorpost-GA, and during the launch window ships asminorwith the banner plus the ADR-0087 disposition as "the only signal there is".⇒
minorstays (the launch-window level for a bannered breaking change;majorwould promote all ~70 lockstep packages), and the changeset now carries:textfield whose definition declaresvalueDomain, where the WRITTEN value is not a member of the named standard — the remedy (write a member, or drop the declaration), and the fact that no stored row is touched or invalidated;adr-0087:disposition whose VALUE is unchanged atnot-required (no-migration-prescription)and whose REASONING was rewritten off the false never-shipped fact and onto the stored side, which survives the release: nothing authorable is renamed, retired or tombstoned; a stored non-member is never re-read, so no stored row is invalidated and none is reachable by a ledger entry; and which member a stored non-member should have been is authoring intent no ledger entry can decide — the refusal reaches the author at the write, naming the standard. That is the ground the sibling accept-set narrowing fix(objectql): the boot loop refuses a view container whosenamedisagrees with its derived object key (#14666) #15319 stands its ownno-migration-prescriptiondisposition on, read from its changeset rather than recalled.Measured, not asserted, that the banner changed what the gate does.
check-adr-0087-registration.mjs --base <merge-base>at the pre-patch headaa4cb1beeprinted «this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)» — the changeset was never judged. Atcb5f6d809the same command prints «1 declared-breaking changeset(s), each carrying an ADR-0087 disposition», exit 0, so the honesty check onno-migration-prescription(which refuses the disposition if the body carries a migration prescription) actually ran and passed. Confirmed independently onfindMigrationPrescription:nullon this body, non-null on a control.Deviations from the declared file surface
The claim declared six paths. Eight changed. Both extras are mechanical completions of this diff, demanded BY NAME by the gate that found them, and each is one commit that reverts alone.
packages/spec/liveness/state-counts.md— a fourthpackages/specpath, and the spec lane's pre-authorisation is three. It is not a hand edit: it is the GENERATED count artifact, rewritten wholesale by the command the gate prints (pnpm --filter @objectstack/spec gen:liveness-counts), which its own error text forbids hand-patching. The diff is two lines:fieldplanned 3 to 2 and live 89 to 90,classifiedunchanged at 93 — so no property entered or left the walked shape. The re-read that number forces was done:liveness/README.mdcarries no prose naming the field planned set. If the spec seat reads a generated follower as inside its three-file authorisation, nothing more is owed; if not, this commit is the one to drop.packages/runtime/src/dispatcher-error-vocabulary.ts— one verdict row forvalue_domain, copied from its six identical siblings (min_length,max_length,min_value,max_value,max_scale,required):verdict: 'foreign-vocabulary', becausefail(code: FieldErrorCode, ...)reachesApiError.details.fields[].codeand nevererror.code, so no ADR-0112 D6 ledger row is owed. This is the same A2-style shape the spec half hit and the seat ruled on for driver-sql'sFIELD_KEY_STORAGE_CLASS, one file and one table entry. Four-condition check: (i) same defect class — a ledger learning the code this very card introduces; (ii) mechanical — the correct form is pinned by six byte-identical siblings and the gate names the missing row; (iii) the file is not a declared at-most-one-writer path (check-single-claim-paths.mjs's list is.objectui-shaalone); (iv) same gate family, no new verification surface.A third, smaller deviation inside those two: the first spelling of that verdict row carried
#14168inside the runtime STRING, whichcheck:doc-authoringrefused ("a runtime string reaches authors, operators and generated surfaces — none of whom can resolve#NNNN"). The id was stripped from the string, per maintainer ruling 2026-08-12; the anchor stays in git history. ⛔ The baseline file was not touched — it is maintainer-only.Nothing else under
packages/specwas needed.field.zod.ts,value-domain.zod.tsanderrors.zod.tsare untouched.Reverse verification
One ablation from the COMMITTED state, subject resolved through SOURCE (the test imports
./record-validator.jsrelative — nodistin the loop, so no rebuild leg applies):The direction is plain red, as expected. The same seven cases — the three refusals, their three update twins and the message pin — were also red BEFORE the seam was written (the tests went in first), and the ten that stayed green in both legs are the admissions, the subset pin, unchanged-on-read and the required-handling pins: they do not read the seam, which is what makes them controls rather than passengers.
Out of scope, untouched
#15134 (the third IANA probe in
packages/core), the settings door's copies (domain:services), the objectui Studio control. Each has its own card. #14238 sequences after this one.Landing
Clause-② is
yes— this narrows a published write path's accept set. Draft,needs:contract-reviewon this PR and on the card in one stroke. ⛔ Not flipped ready, no auto-merge, not enqueued: that is the reviewer's to arrange after a PASS atCONTRACT_REVIEW_TIER.origin/mainmoved 4 commits during this round (last derivation139d43ae6vs3a4373f4c), and 9 of the files the family derivation reads changed in that range — all gate scripts, one of which (check-refd-timer-probe.mjs) is in the run set above. The union was re-run at139d43ae6after the final commit; CI re-runs the farm on the true merge.Generated by Claude Code
Patch round —
39eadd729Two CI reds at
139d43ae6, both this PR's own, both fixed. A third red,TypeScript Type Check, was the rollup counting the second one again — nothing separate.Red 1 —
Test Core (3/6): the shipped-predicate census in@objectstack/lintpackages/lint/src/validate-predicate-path-refs.test.tscounts the predicates the shipped metadata forms carry:expected 53 to be 51andexpected 19 to be 18.The cause is measured, not inferred from the counts. The corpus was enumerated on this tree and on the merge base
5b09356b7through the same walk the test uses, and the two lists were differenced by the triple FORM::FIELD::SOURCE rather than by array index — an inserted row shifts every later sibling's index, and a positional diff drowns in 27 added / 25 removed rows of pure churn. Index-free, the delta is:Exactly the two rows this PR adds, nothing else moved, and the object-form half of that delta is the single
data.type-rooted predicate the second assertion counts (18 to 19). Both constants moved and the shape did not: they stay exact-equalitytoBe, notoBeGreaterThan, no case deleted — one asserts the walk is not vacuously empty, the other is a reverse-verification control, and both keep their jobs. Each new constant carries its measurement in the comment beside it, in the census style the file already uses.pnpm --filter @objectstack/lint testexit 0,Test Files 94 passed (94) / Tests 2900 passed | 5 skipped (2905).expected 51 to be 53. The corpus enumeration had rebuiltpackages/specat the merge base to take the BASE reading, and the restore leg put the SOURCE back without rebuilding — so@objectstack/lintwas still resolving the merge-basedistthrough the package specifier. The restore leg of an ablation owes the same rebuild the mutation leg does; here the already-moved constants turned that into a loud red instead of a silent measurement of the wrong tree.Red 2 —
Type Check · consumer gates:check:i18n,platform-objects DRIFTED (7)⭐ This is one of the three families the first round reported as
exit 3= NOT MEASURED. With the workspace closure built it is a real red, and the handling of the first round is what made it legible rather than mysterious.Regenerated by the writer the gate names —
node scripts/check-i18n-bundles.mjs --write— never by hand. The drift is exactly the two form rows, measured: 44 lines added and 0 removed across the 7 files, and every added line is avalueDomainkey — thelabel/helpTextpair formetadataForms.field.fields.valueDomainandmetadataForms.object.fields.fields.valueDomain, plus their source-hash entries in the three translated locales.zh-CN/ja-JP/es-ESleaves carry the ENGLISH source text, and that is correct. It is the extractor's merge mode: an existing translation is never overwritten, and a new schema key arrives filled from the source pending translation. Not drift, not a mistake, and not this card's to resolve. Mechanically confirmed rather than merely asserted:check:i18n-stale-fill— newly implicated by these paths and run for the first time this round — reports0 stale-fill leaf/leaves, 0 baselined, exit 0.The three NOT-MEASURED families are now measured
The closure was built once (
turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*', 71/71 successful) and all three ran to a real exit code:check:i18n0 (after the regeneration),check:dual-build-cjs-loads0 (102/66/610/1 against floors 90/58/520/1),check:type-check-debt0 (14 ledger entries re-measured, 153 raw errors, none above its record). Thetypecheck-debtlane mapping was read rather than trusted —lint.yml:4679names the jobType Check · debt ledgerand its step at:4886runspnpm check:type-check-debt— so the CI success on that lane and this local exit 0 are the same measurement.Why the 67-family derivation could not see Red 1 — the PM's hypothesis, half confirmed and half falsified
Confirmed: those families are
check:*gate scripts derived from PATHS. A package test suite in a package this diff does not touch, reading a corpus this diff does change, is not in that population at all.Falsified —
check:cross-package-test-inputsdid not miss anything, and no card is owed. Its population is tests whose reads escape their package through the FILESYSTEM (import.meta.url,__dirname, afindUpwalk) — precisely the class turbo's dependency graph cannot see, which is the defect its header documents. The lint test escapes nothing: it doesimport { METADATA_FORM_REGISTRY } from '@objectstack/spec/system', an ordinary import of a declared dependency (packages/lint/package.jsonlists@objectstack/spec": "workspace:*"), and it contains none of the escaping spellings. Measured:turbo ls --affectedagainst the merge base returns 75 packages and@objectstack/lintIS among them. So CI's Layer A worked exactly as designed — which is how this was caught.⇒ The gap was in the local suite selection, not in any gate. The suites run in round one were chosen as "packages I changed" plus "the eight test files that read the specific file I edited". Neither question is the one CI asks. The question that would have caught it is
turbo ls --affected, and it is now part of this PR's evidence: of the 75 affected packages, the two directly implicated and not previously run —@objectstack/lintand@objectstack/platform-objects— are now both green (lint2900 passed / 5 skipped,platform-objects518 passed / 33 files, plus both typechecks).Gate families re-derived and re-run at
39eadd729The surface is now 16 paths across 5 packages, so the derivation was re-run: 68 families, one more than before (
check:i18n-stale-fill, pulled in by the platform-objects bundles), and 68 / 68 exit 0 — no NOT-MEASURED rows left. Beyond the union:pnpm lint(whole-repo eslint,--no-inline-config) exit 0;@objectstack/objectql4643 tests,@objectstack/spec12582 tests,@objectstack/lint2900,@objectstack/platform-objects518, all exit 0;objectql/spec/runtime/lint/platform-objectstypechecks exit 0.Deviations, now five, all pre-authorised by the dispatching PM
packages/spec/liveness/state-counts.mdandpackages/runtime/src/dispatcher-error-vocabulary.ts(accepted this round, with thestate-counts.mdreading flagged to thedomain:specseat as the PM's rather than the seat's), pluspackages/lint/src/validate-predicate-path-refs.test.tsand the seven generatedpackages/platform-objects/src/apps/translations/*.generated.tsbundles (pre-authorised this round). Every one is a corpus or ledger that learns about rows THIS card adds, each is one commit that reverts alone, and nothing else inlintorplatform-objectswas touched.Still draft. Not ready, not enqueued,
needs:contract-reviewon both carriers. Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJDocs round —
aa4cb1beeThe one page this diff falsifies
content/docs/data-modeling/validation-rules.mdx, thevalueDomainrow (verified at:47on the tree actually edited, not inherited from an earlier reading). Its parenthetical stated a PENDING condition — "the engine half of the same ruling — until it lands, the declaration is accepted at parse and not yet enforced on writes" — and this PR is that landing, so the sentence becomes false at merge. It is also the one sentence in the docs an author reads to decide whether the key can be relied on today.A deletion and nothing else:
1 insertion(+), 1 deletion(-), one line. Everything else in the row is still true and is left byte-for-byte — the membership-not-shape explanation, theIntl.DateTimeFormatprobe note, the^[A-Z]{2}$-admits-ZZcontrast, and thetext-only clause were each re-grepped as present after the edit.The four the drift bot also named, and why they need nothing
data-modeling/fields.mdx,data-modeling/field-types.mdxandprotocol/objectql/schema.mdxdescribe the key by MEANING ("Standard the written value must be a member of"), true before and after;api/error-catalog.mdxdescribesvalue_domainas the code for a non-member, which landing makes more true, not false. Untouched.No release-owned page is involved. The drift comment on this PR names five pages and none is under
content/docs/releases/; the branch's own file list confirms it — 17 paths, zero undercontent/docs/releases/.Gates re-derived and re-run at
aa4cb1beeThe docs path widens the surface to 17 paths, and the derivation grew from 68 families to 88 — twenty new ones, all pulled in by that single
.mdx(check:doc-authoring,check:doc-anchors,check:docs-single-h1,check:corpus-claim-drift,check:docs-redirects,check:doc-frontmatter,check:docs-section-name,check:section-landing-index,check:doc-route-spelling,check:role-word,check:published-readme-links,check:docs-audit-scope,check:doc-security-posture,check:skill-examples,check:skill-identifier-liveness,check:react-page-adapter-contract,check:vendor-version-stampsand their self-tests). 88 / 88 exit 0, no NOT-MEASURED rows — the workspace closure was restored from turbo cache in 714ms, socheck:i18n,check:dual-build-cjs-loadsandcheck:type-check-debtare real measurements here too.pnpm lint(whole-repo eslint) exit 0.⭐ The transferable lesson from this round, stated plainly
The restore leg of an ablation owes the same rebuild the mutation leg does. Taking the merge-base reading rebuilt
packages/specat the base; the restore put the SOURCE back and did not rebuild, so@objectstack/lintwent on resolving the merge-basedistthrough the package specifier and the next measurement was of the wrong tree. It only became visible because the census constants had already been moved, which turned it into a loud red instead of a silent green — with the constants untouched it would have measured the wrong tree and said nothing.The sibling lesson, from the same round: a local suite list derived from "packages I changed" is not the question CI asks.
turbo ls --affectedagainst the merge base returns 75 packages here,@objectstack/lintand@objectstack/platform-objectsamong them — both of this round's reds were inside that set, and neither package is one this diff's first round would have thought to run.Still draft. Not ready, not enqueued,
needs:contract-reviewon both carriers. Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJGenerated by Claude Code