Repository navigation
Refuse an unreadable reference carrier at the ten residual readers (ruling E item 2 residue) - #19080
Conversation
…gh the one arbiter Ruling letter E item 2 asked for the loud refusal at EVERY reader of `FieldSchema.reference`. PR #18503 delivered it at the arbiter (`referenceCarrierOf`) and the lint read sites; these ten reads still answered "no target" for a carrier no reader can read. Each site keeps absence and unreadability as DIFFERENT answers: `null`, `undefined` and `''` still answer `undefined` and are still silent (the key is `.optional()` and `StrictField` declares it nullable); only a carrier in a shape the contract does not admit refuses. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
…uted reader Every refusal case has an absence partner (`undefined` / `null` / `''`) and a positive control, so a harness that had stopped exercising the reader could not pass vacuously. The objectql suite also pins that nothing is written when the refusal fires, which is the measured defect inverted. The four lint sites take the form the already-routed lint readers use: the literal `.reference` read stays at the site so the #5017 receiver meta-test keeps its subject, and only the shape judgment moves to the arbiter. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
… presence `check:objectql-double-limit` graded the new file's `find` double limit-blind. Fixed at the double — the baseline never grows. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
… list `relationTarget`'s subject is WHICH KEY it reads, stated at length in its own docblock, so the read stays greppable in the function body and only the shape judgment moves to the arbiter — the same form the lint readers use. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 38 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 90bbbf7ebb4d2a43276dad5f3d11b6f058f031c9 && git checkout 90bbbf7ebb4d2a43276dad5f3d11b6f058f031c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 176b03582e600ee5628d21bff9422073c5a5530c a17094d9482575b014edce04224a0426e4e93712 && git checkout -B drift-repro 176b03582e600ee5628d21bff9422073c5a5530c && git merge --no-ff a17094d9482575b014edce04224a0426e4e93712
node scripts/docs-audit/affected-docs.mjs --json 176b03582e600ee5628d21bff9422073c5a5530c
|
…n the page The route's error table enumerated four outcomes and now has a fifth: a stored `reference` holding a non-string declares a target the route cannot read, and answers the sanitised fault rather than LOOKUP_TARGET_MISSING. The page said "could not be resolved from the field definition" for the missing-target code, which read as covering both — the distinction this routing exists to make. Found by hand, not by the drift bot: `rest-server.ts` yields no doc anchor, so pages documenting it are invisible to that run by its own declaration. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
|
The event names head Measured, ⛔ not pattern-matched
⇒ the push cancelled the in-flight run and the aggregator turned its cancelled members into a One difference from the sibling case, stated because it weakens the reassuranceOn PR #19076 the same artifact appeared and the superseding commit changed no TypeScript at all, so a genuine type error could not have hidden behind the supersession. ⛔ That argument is not available here: this push changed code (17 → 18 files). So the only support for 「artifact」 is the cancelled-member evidence and the three-second duration — and the actual verdict is whatever the four ⇒ if a real type failure exists it will appear there, and it will be this PR's to fix. This comment is not a claim that the PR is green. ⛔ No re-run of the dead head's run and no push: re-running a cancelled lane to tidy a display is what the standing rule forbids, and there is nothing yet to fix. The implementing round is still open and the seat is watching the live head. Generated by Claude Code |
Contract reviewHead reviewed: Reviewed-by: isolated at-tier reviewer commissioned by Tier established by the reviewer as its first act, before any review work, by grepping its own transcript: Verdict: CLAUSE-② PASS. All five blocking items pass. Reviewed in the reviewer's own worktree at the head above;
Disposition of the carrierBoth carriers were cleared seconds apart after this record was written, and this comment is the record the clear cites. The declaration stands as Findings 5, 7, 8 and 10 are non-blocking and are weighed on the card (#18550, comment 5733563414), together with three wording nits the seat deliberately did not push, because a push moves the head this verdict is bound to: the REST pin has no Generated by Claude Code |
Contract reviewServed-tier: 6/6 Supersedes comment 5733580409, which carried the same verdict and the same readings but failed the record contract twice: the head was under a key of this seat's own invention (「Head reviewed:」 rather than ① Derived judgmentsTen items reviewed by an isolated at-tier reviewer in its own worktree at the head above; Blocking items, all PASS:
Non-blocking findings: item 4 (no error code added; an existing sanitised ② Semver level
③ Boundary flagsNo security or permission boundary moves. No published contract surface is widened, no export, key or error code is added, and
Implemented-by: VERDICT: PASS Generated by Claude Code |
…pagating an unreadable `reference` (objectstack-ai#19197) Fixes objectstack-ai#19081 Clause-②: no Four readers of `FieldSchema.reference` gated the carrier with a **truthiness** test, which a non-string object passes, and then propagated the value onward. The declared contract is an optional **string**, so the answer a reader owes for a carrier it cannot read is **absence**. The approvals site did worse than lose the information — `out.push({ key, reference: String(f.reference) })` **manufactures** the literal target name `[object Object]`, and hands it on as an object name to `engine.find()`, where the failure disappears into the caller's own `catch`. These four are the class PR objectstack-ai#18503 labelled **C2** and deliberately left unchanged. This PR acts on that class; it is not a claim that objectstack-ai#18503 or objectstack-ai#19080 was wrong to leave it, and it establishes no reachability from an authored document — the engine's write path refuses this shape, so a row carrying it had to be written straight through the driver. Cheap insurance, not an incident. ## The design question the card leaves open, and what decided it Whether these sites should route through the arbiter `referenceCarrierOf` or narrow locally. Measured first, because the arbiter does **not** return the same narrowing a local `typeof` test would: it **throws** a `TypeError`. That is extra knowledge, and at these four sites it is hostile knowledge: | site | what an escaping throw would do | |---|---| | `ApprovalService.resolveLookupFields` | the whole body is inside `try { … } catch { return []; }`, so one unreadable field would drop **every** lookup field of the object | | analytics relationship resolver | a `TypeError` inside dataset compilation, replacing the compiler's own "cannot resolve this relationship" refusal with a crash | | `os doctor` (both sites) | aborts the diagnostic run on exactly the broken metadata `doctor` exists to report | So the repair is **both**: the carrier is read through the one arbiter — no fourth hand-copy of the narrowing, and `''` / `null` / `undefined` keep their absence semantics — and the refusal is caught **at the site**, which yields absence plus one report. That is the deliberate line between these readers and the `@objectstack/objectql` cascade seams objectstack-ai#19080 landed, which let the same refusal propagate: those assert something positive about the schema on a **write** path, where the silence cost an orphaned `master_detail` row and a reported success. ## Per site - **`@objectstack/plugin-approvals`** — the unreadable field is left out of the inbox display enrichment and logged through the service's existing `logger.warn`. It is **dropped** rather than pushed with the target absent because the sole consumer destructures `{ key, reference }` and uses `reference` as the object-name argument to `engine.find`; an entry carrying none has nothing for that consumer to do, and keeping it would widen the declared return type for no reader. The effect there is the one this best-effort resolver already produces for every other unresolvable case — the entry stays unresolved. - **`@objectstack/service-analytics`** — the ADR-0021 relationship resolver answers `undefined`, which its existing fallback turns into the dataset compiler's refusal, plus one `ctx.logger.warn` naming the field. - **`@objectstack/cli`** — `os doctor`'s circular-dependency and unused-object checks **report** the unreadable carrier as a finding rather than skipping it. Both publish a positive verdict — "No circular references detected", "defined but not referenced" — that an edge nobody could read cannot support, and both already return diagnostic strings that `doctor` prints as warnings, so being loud here needed no new channel. The same file's `collectViewObjectRefs` already narrowed its own carrier. ## Tests, and the ablation behind them Each of the four sites has a case that fails **before** the change and a readable-target control beside it, so "narrowed" and "this path is now closed" stay distinguishable. The `[object Object]` string is pinned directly, since it is the card's whole evidence. Ablation: each narrowing was reverted to its truthiness gate through `scripts/ablation-replace.mjs` (anchor must hit; on-disk counts and blob hashes are the tool's own verdict), the pin re-run, then restored. Every site is source-imported by its suite, so no `dist` round trip is involved. Restores verified by the tool: `blob == HEAD` and `git diff HEAD` empty. | site | ablated run | |---|---| | approvals | 3 failed / 1 passed — `expected [ 'crm_account', …(2) ] to not include '[object Object]'` | | analytics | 2 failed / 1 passed — `expected { name: 'shop_invoice', fields: {} } to be undefined` | | doctor (both) | 2 failed / 3 passed — the two carrier findings vanish | ## Verification All commands run at `1130dc81b`. - `pnpm --filter @objectstack/plugin-approvals --filter @objectstack/service-analytics test` — exit 0; 48 files / 775 tests and 113 files / 2411 tests. - `pnpm --filter @objectstack/cli exec vitest run --project unit` — exit 0; 218 files / 3069 tests. The `integration` tier is declared to CI: the diff touches no integration-tier test file, no `bin/` entry and no driver or kernel boot path. - `pnpm --filter … typecheck` for the three packages — exit 0, test layer included. - Gate family re-derived for the actual diff with `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: **64** families, each run with its exit code landed to a file, then reconciled with `--ran`: **60 run, 4 NOT MEASURED, 0 UNRUN**. The four are `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity`, each exiting **3 = PREREQUISITE NOT MET** in this container because they read whole-tree built output. Not a pass — declared to CI. - `eslint . --no-inline-config` — exit 0 over the **6900** files eslint's own config reads, 0 errors / 0 warnings. Type-aware linting is not enabled in `eslint.config.mjs`, so no untouched file's verdict can move with this diff; this is the full population rather than a narrowing. ## Acceptance notes - `packages/cli/src/commands/doctor.ts`'s `detectCircularDependencies` gained an `export` so its carrier reading is assertable; it has no other caller, and `doctor.ts` is not a declared entry in this package's `exports` map, so nothing is added to the published API surface. That is the only change in the file beyond the two gates the card names. - Census re-run. The card's reading — one `referenceCarrierOf` hit across `packages/objectql`, `plugin-approvals`, `service-analytics` and `packages/cli`, and it is a test — no longer holds for that four-package corpus: `packages/objectql/src/engine.ts` now carries four production hits, landed by objectstack-ai#19080 after the card's ref. Inside this PR's three packages the zero stood (only `packages/cli/test/data-model-rules.test.ts`), with the token resolving in 20 files repo-wide as the live control. - Noted, not filed: `collectViewObjectRefs` (same file) narrows with a bare `typeof` test, so it admits `''` as a target name and reports nothing when a carrier is unreadable. Out of scope here — it is not one of the four propagating sites, and it already answers absence rather than propagating. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…`reference` carrier is unreadable (objectstack-ai#19293) Fixes objectstack-ai#19082 Clause-②: no A diagnostic added to an internal, private index neither loosens an accept set nor widens a published surface. No schema changed; `buildSummaryIndex` is `private` and nothing about its signature, its return shape or its resolution rule moved. ## The premise, re-taken by symbol Triage said it had not re-taken the reading and asked the executor to. `packages/objectql/src/engine.ts` took a lander after the card's reading ref `221dabb72` — `a675ad4e` (objectstack-ai#19080, the ten-residual-readers round) — so the site was re-located by **symbol**, never by the card's line numbers. It still resolves by carrier equality. `buildSummaryIndex` is at `engine.ts:9009`; the comparison the card quotes at `:9033` is byte-identical and now sits at the same line, and the silent `continue` at `:9039` is unchanged. objectstack-ai#19080 routed `planCascadeAtomicity` and `cascadeDeleteRelations` through the arbiter and left this third site alone. **`premise_still_valid: true`.** ## The defect The child-to-parent foreign key is resolved by scanning the child object's `master_detail` / `lookup` fields for one whose `reference` names the parent. That comparison read the carrier raw, so a carrier **no reader can read** — a non-string, where `FieldSchema.reference` declares an optional string — compared `false` against every name, `fkField` stayed unset, and ```ts if (!fkField) continue; // can't resolve the relationship — skip ``` dropped a **declared** `summary` field out of *both* indexes. `recomputeSummaries()` then had nothing to do after every insert / update / delete of the child, so the parent's stored summary value kept whatever it held while each of those writes reported success, and nothing anywhere said so. It is the second way this one function invents *"nothing to recompute"*; the first, its registry read, was closed as objectstack-ai#9154. ## The boundary this card asked to reopen — and where it now stands PR objectstack-ai#18503 recorded this site in its **C2** list and the objectstack-ai#18550 round left it there deliberately. **That boundary stands: the resolution rule is untouched.** Loosening the comparison would trade a silent stall for a **mis-matched foreign key**, which is more expensive — a roll-up quietly aggregating the wrong children reads exactly like a correct one, while a roll-up that stopped moving is at least visible to anyone who looks at the value. What ends here is only the **silence**, which triage named as the half available today: - the carrier is read through the one arbiter, `referenceCarrierOf` — the accessor objectstack-ai#19080 routed the two cascade seams through; - its refusal is **caught** rather than propagated, because this is a *scan* looking for the FK across every relation field: a propagating refusal on one unreadable field would hide a readable sibling that really is the foreign key, turning a roll-up that works today into a hard failure of every write to that child. Pinned (§5 of the new test); - the skip reports itself at **`error`**, once per index build. A persisted summary that silently stops tracking its children while every write keeps reporting success is the durability class by AGENTS.md's own question, and the line carries both halves it owes: the consequence (which field will not recompute, and that the system keeps looking healthy) and the fix (spell the carrier as the target object's name, or name the FK with `summaryOperations.relationshipField`); - **absence is untouched.** `undefined`, `null` and `''` mean "this field names no target", which is legal; they skip silently exactly as before. Every readable carrier resolves exactly as before. The decision and its reasoning are recorded on the card and in the function's own docblock, so the next reader of the skip branch finds them instead of re-filing. ## Reachability — measured, and deliberately not inflated The card recorded this as **not established**, and it is now measured on this tree rather than argued. One probe, three doors, each with a readable-carrier control that passes: | door | shape `{ object: 'bad' }` on a `master_detail` | control `reference: 'bad'` | |---|---|---| | `ObjectSchema.safeParse` (the contract door) | **REFUSED** — `fields.bad.reference: invalid_type` | accepted | | `getMetadataTypeSchema('object')` — what `saveMetaItem` resolves for a stored `/meta` write | **REFUSED** | accepted | | `registry.registerObject` — the choke point every metadata door funnels through | **ACCEPTED**, carrier stored verbatim as `{"object":"bad"}`; `referenceCarrierOf` on the registered field throws | accepted | So: **not a live outage** — the live authoring and stored-write doors refuse this shape today — and **not unreachable either**. The registry takes it raw, which is the population `engine.ts`'s own objectstack-ai#9689 note already names for the sibling seam: "a raw `registerObject`, or a stored/artifact row written before the tightening — the two populations parse-time rejection measurably cannot catch, since the engine registers raw objects and never re-parses". Graded exactly there, and ⛔ not escalated: no stored `summary` field was measured to have never recomputed, which is this card's only escalation condition. One honest qualifier, measured in the same probe: registration **does** already emit an ADR-0078 completeness warning for this field (`field/relationship-without-reference` fires on `typeof def.reference !== 'string'`). That is a one-shot, console-carried note about the **child field** at registration; it does not name the **parent's** declared `summary` field, does not say the roll-up was dropped, and this package's own vitest config quiets `[Registry]` output to `warn`. It is a neighbouring signal, not this one. ## Tests `packages/objectql/src/engine-summary-index-unreadable-carrier.test.ts`, 7 cases, both directions — because without the second, a change that simply stopped resolving anything would be indistinguishable from a fix: - **§1 control** — a normal `reference` still resolves `fkField` (`inv_line` / `inv`), and the recorder stays at zero; - **§2 the defect** — an unreadable carrier emits the skip signal, at `error` and not `warn`, naming the field, the consequence and both fixes; - **§3** — both in one index build: the readable roll-up is indexed while the unreadable one is reported; - **§4** — absence stays silent; - **§5** — an unreadable sibling declared *before* the real FK does not hide it; - **§6** — said once per index **build**: five consults report once, and a registry mutation makes it report again (without that second leg a "1" could equally mean "once per process"). The zeros in §1, §4 and §5 are readings rather than a dead instrument: §2 drives the same recorder through the same handle and measures it at 1. Every command below captured its exit code before any pipe, at HEAD `308a3403`: | command | result | |---|---| | `pnpm --filter @objectstack/objectql test` | **301 files / 5016 tests passed** | | `pnpm --filter @objectstack/objectql typecheck` | exit 0 — and `check:test-typecheck` holds at 40 files / 234 errors / 65 signatures, unmoved | | `pnpm --filter '@objectstack/objectql^...' build` | exit 0 | | `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` | 72/72 successful | | `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` then `--ran` | **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN** — every family carries a recorded exit code and none is 3 | | `pnpm lint` (`eslint . --no-inline-config`, whole repo — no narrowing to declare) | exit 0 | Five of the 62 first returned exit 2 or 3 — never a pass, nothing measured — and each was cleared rather than reported as one: `check-engine-split-ratio` and `check-plugin-teardown-shape --self-test` refused on a shallow clone (deepened with `git fetch --shallow-since=2026-06-15`, both then exit 0), and `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` refused for want of built output (built, then exit 0). ## Acceptance notes Out-of-scope observations, noted and deliberately **not** filed: - **noted, not filed** — the `!fkField` skip is still silent in its *other* branch: a roll-up whose child declares **no** relation field at all is dropped with no diagnostic here. It is not this card's input, it is loud at a different layer (the ADR-0078 completeness rule fires on exactly that shape at registration, at `severity: 'error'`), and widening the new diagnostic to cover it would make every legitimately-unresolvable `summary` declaration log per index build. Successor: whoever next reopens PR objectstack-ai#18503's C2 boundary for this function — the decision is now recorded in `buildSummaryIndex`'s docblock, where they will meet it. - **noted, not filed** — when an unreadable sibling carrier sits beside a readable FK that does resolve, the unreadable one is passed over silently (§5 pins that it does not break resolution). Nothing is dropped on that path, so there is no defect to report; the carrier itself is already reported by the ADR-0078 rule at registration. Successor: none — no PR or reader reaches this path with a question the ADR-0078 warning does not already answer. Sibling card objectstack-ai#19081 shares the same root (an unreadable `reference` carrier) and is deliberately **not** folded in: different file, different failure direction (it leaks the carrier onward; this one silently drops work), different lane. Triage ruled both should be taken, in either order. --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18550
Clause-②: yes
Ruling letter E item 2 on #18095 asked for the loud refusal at every reader of
FieldSchema.reference. PR #18503 delivered it at the arbiter (referenceCarrierOf) and the lint target readers, and named the remainder itself under "What this PR does not close". This is that remainder, routed — with a per-site decision about absence vs unreadability, because in this design they are deliberately different answers.JOB ONE — the count, re-derived per line. ⛔ Nothing inherited.
Triage's instruction was 「⭐ 数是 9,⛔ 不是 PR 正文写的 10 —— 契约复核重新数过。⭐ 这正是本班刚立的那条:计数是会腐烂的读数 ⇒ 派发时再数一次,⛔ 不要把 9 或 10 任何一个往前抄。」 So neither number is carried forward here. What the re-derivation found is that 9 is not reachable at any single granularity — it mixes two.
Every cited line was printed at the card's stated merge base
2f11e2db7and re-located onorigin/main@0ec81857a:2f11e2db7)objectql/src/engine.ts:1305213096const ref = fdef.reference;objectql/src/engine.ts:1349113544const ref = fdef.reference;rest/src/rest-server.ts:1083510889referenceObject = def?.reference;metadata-protocol/src/seed-loader.ts:701701fieldDef.referencelint/src/validate-expressions.ts:380380const ref = def.reference;lint/src/validate-field-consumers.ts:552552const reference = strName(field.reference);lint/src/validate-object-references.ts:297297strName(field.reference),lint/src/validate-object-references.ts:316316strName(param.reference),lint/src/validate-sharing-rule-enforceability.ts:261261const ref = f.reference;verify/src/derive.ts:136136const ref = f?.reference;That is 10 cited LINES in 8 FILES. Neither is 9. The 9 is reachable only by mixing granularities, and triage's own parenthesis shows the mix: 「objectql ×2、rest、metadata-protocol、lint ×4、verify」 = 2 objectql LINES + 3 non-lint FILES + 4 lint FILES = 9, counting objectql per line and lint per file. PR #18503's own "10 sites" is its 11-line C3 list (the 10 above plus
validate-preset-comparands.ts:431) with one pair merged. Both numbers are arithmetic over the same list; the list is the fact, the count was the rotting reading.validate-preset-comparands.ts:431(now:450) is confirmed not residue, on the same reading the contract review gave: it readsverdict.meta?.reference, andmetais theGraphFieldslicegraphFieldOfbuilds — which PR Retire check-reference-carrier-shape; refuse an unreadablereferencecarrier at the reader #18503 routed. It is covered transitively, and so areobject-graph.ts:383and:386.objectql/src/engine.ts:9033(cd?.reference === parent.nameinbuildSummaryIndex) produces the same silent skip — "can't resolve the relationship — skip", so asummaryfield never recomputes. It is already classified, asengine.ts:8989in PR Retire check-reference-carrier-shape; refuse an unreadablereferencecarrier at the reader #18503's C2 list, a boundary that PR drew deliberately. Routing it would widen this card past its scope; it goes to the seat as a finding instead.The "four routed lint read sites", re-derived
The card paraphrases PR #18503 as delivering the refusal "at the arbiter plus the four lint read sites". The PR's own words are "A — changed here (6 read sites, 4 files)", and one of those four files is
packages/spec/src/data/field-value.zod.ts— the arbiter itself, not lint. Measured onorigin/main:referenceCarrierOfcalldata-model-rules.ts,object-graph.ts,validate-security-posture.ts)refOf,graphFieldOf,refOf)refOf(×4 indata-model-rules.ts, ×3 invalidate-security-posture.ts,graphFieldOf(×2 inobject-graph.tsSo four lint read sites is not a reading at any granularity: it is 3 files / 3 calls / 9 read sites, plus the arbiter's own file as the fourth FILE. The seat's lit control reproduces exactly (2 grep hits per file = 1 import + 1 call).
The C1 set, re-derived — the card is right, and the PR body is wrong in the other direction too
C1 was "explicit
typeof === 'string'narrowing — the same silence spelled differently". Re-derived repo-wide overpackages/*/src:The card's C1 list, checked one by one:
plugin-audit/src/audit-writers.ts:472, 527, 629, 693''/undefined, audit rows lose the targetrest/src/export-format.ts:170reference: undefinedin the export meta, soreferenceFieldNamesomits the column and it exports raw ids with no$expandcli/src/commands/doctor.ts:792spec/src/kernel/functional-completeness.ts:167(now:177)FIELD_RELATIONSHIP_WITHOUT_REFERENCE, severity error. Measured, not read: registering a fixture with an object carrier printed[Registry] Object "task" registered with 1 functionally-incomplete field(s) … account: [error] field/relationship-without-reference⇒ 6 genuine C1 sites, not 7.
And what the card's list gets wrong in the other direction — the thing it asked to be told:
typeof === 'string'narrowings, inline or through astrNamehelper that is one:validate-expressions.ts:380,validate-field-consumers.ts:552(strName=typeof v === 'string' && v.length > 0),validate-object-references.ts:297and:316,validate-sharing-rule-enforceability.ts:261,verify/derive.ts:136. The PR body's C1/C3 split reads as two populations; at six of ten sites it is one code shape sorted into two classes. Only four residue lines are a genuinely different shape: two truthiness gates (engine.ts×2), one truthiness-plus-cast (seed-loader.ts), one bare assignment with no narrowing at all (rest-server.ts).spec/src/automation/builtin-node-config.zod.ts:527—typeof field.reference === 'string' && field.reference.trim() !== ''inside asuperRefine, falling through toctx.addIssue(SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED). Sofunctional-completeness.tsis not the lone misclassification; there are two C1-SHAPED reporters, and ⛔ neither may be made to throw — a throw inside a refinement makessafeParsethrow instead of returning{success: false}.undefined(C2 by the PR's classification, ⛔ not touched here, reported as findings):plugin-approvals/src/approval-service.ts:5773doesString(f.reference), which turns an object carrier into the literal target name[object Object];service-analytics/src/plugin.ts:736returns the unnarrowed carrier;cli/src/commands/doctor.ts:709and:895push it into a dependency graph.The judgement per site — absence or unreadability, and the pin
referenceCarrierOfis the whole of the contract used here:undefined/null/''answerundefined(ABSENCE — a field is allowed to name no target;FieldSchema.referenceis.optional()andStrictFielddeclares it nullable), and any other shape throws aTypeErrornaming the shape and the fix. ⛔ No site throws on a falsy carrier. Every refusal pin has an absence partner and a positive control.objectqlplanCascadeAtomicityif (!ref)and then failed both name comparisons, so the child dropped out of the referencing set and'none'— the one verdict asserting nothing references this object — could be returned over a schema nobody could readengine-cascade-reference-carrier.test.ts"seam 1"objectqlcascadeDeleteRelationsrestpublic-form lookup pickercatch {}; routing alone would have been swallowed intoLOOKUP_TARGET_MISSING, so the field def is hoisted out and read after it. An object carrier was also forwarded verbatim asquery.objectintofindDatapublic-form-lookup-picker.test.ts, 4 casesmetadata-protocolbuildDependencyGraphas stringcast that asserted exactly what the truthiness guard had not checkedseed-loader-reference-carrier.test.tslintmasterDetailCountmaster_detailinvisible to the count, soparentwas reported unbound from metadata that declares a mastervalidate-expressions.test.tslintwalkObjectdisplayField edgedisplayFieldconsumer edge was never recorded, so a field a lookup DOES display was reported carrier-onlyvalidate-field-consumers.test.tslintfield target + action-param targetcheckreturns early onundefined, so the declaration reported NOTHING — not unknown-object, not missing-referencevalidate-object-references.test.tslintmasterOfcontrolled_by_parentdetail whose master IS declared answeredundefinedvalidate-sharing-rule-enforceability.test.tsverifyrelationTargetreferencetarget" — the exact trade this reader already refused to make for a rejected aliasderive.test.tsvalidate-preset-comparands.ts:450GraphFieldslice — no edit, measured not assumedengine.ts:9033buildSummaryIndexActionParamSchema.reference— one caveat, stated rather than buriedvalidate-object-references.ts:316reads an ActionParam, not a FieldSchema field. The two are one contract by the spec's own words —ActionParamSchema.reference's docblock: "Key name deliberately mirrorsFieldSchema.referenceso the same spelling", declaredSnakeCaseIdentifierSchema.optional(). The refusal text it now produces says "FieldSchema declares it as an optional STRING", which names the sibling schema rather than this one. ⛔ Widening the arbiter's message would mean editingpackages/spec, outside this card's declared file surface, so it is reported instead of done.Evidence
The silence, reproduced at the representative site, then refusing
objectqlcascade delete — the hot path, and the one whose wrong answer is a production behaviour change. Same probe, before and after the routing:No
restrictrefusal, noset_null, nothing logged, and the caller told the delete succeeded. The refusal now fires before any row is touched, becausedelete()callsplanCascadeAtomicityfirst.⭐ Two facts the probe forced, both worth recording: the engine's own WRITE path already refuses this shape (
insertrunsassertReferencesResolve→referenceTargetOf→ the same arbiter), so the child row had to be written straight through the driver — which is exactly the provenance the arbiter's docblock names (a rawregisterObject, a stored row rehydrated past its schema). And the registry's own completeness check printedfield/relationship-without-referencewhile the cascade stayed silent: the platform reported the shape in one channel and mis-read it in another.Ablation — the pins can fail, proved on disk by content hash
One routed read reverted through
scripts/ablation-replace.mjs(⛔ notsed -i), on the committed tree:Restored with the explicit form, verified independently afterwards:
git diff HEADempty andgit hash-objectequal to the HEAD blob. The redness is targeted — exactly the mutated seam's pin failed while seam 2 and all three absence controls stayed green, so the pins are per-site rather than one shared assertion.Tests
@objectstack/objectql@objectstack/rest@objectstack/metadata-protocol@objectstack/lint@objectstack/verifytypecheck(all five, incl. test layers)objectql's shrink-only test ledger held at 40 files / 234 errors / 65 pinned signatures — not raised⭐
check:type-check-debtre-measured all 4 DEBT ledger entries at the final head: 53 raw errors, none above its recorded number.Repo-wide lint — run, ⛔ not narrowed
pnpm lint(eslint . --no-inline-config) atbd1b3dcad: exit 0, 75s. No narrowing is claimed, so no narrowing evidence is owed.Gate census — derived from the REAL change set
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat the final head, every command run with$?captured before any pipe, then reconciled with--ran:pnpm check:dual-build-cjs-loads—PREREQUISITE NOT MET(exit 3), ⛔ neither a pass nor a finding. Its own words: "this gate reads built output, and some package has no dist/ … Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured." 35 packages outside this card's build closure have nodist/; the prerequisite is a repo-wide build, which is what CI does before this step. Bounding fact, measurable from the diff: zeropackage.json,tsup/tsconfig,turbo.jsonor workflow files are touched — 17 changed paths, all.changeset/pluspackages/**/src.check:objectql-double-limitgraded the new test file'sfinddouble limit-blind. Fixed in the double (the caller's bound applied after the filter, by presence). ⛔ The baseline was not touched; the re-run printsbaseline key set verified against 0ec8185: no files added.check:type-check-debtexited 3 twice on its own prerequisite — first for an unbuilt@objectstack/driver-turso, then because the ablation's restore rewroteengine.tsand leftdist/older than source by mtime. Both satisfied by building, then exit 0. ⛔ No ledger entry was raised on an exit 3, which that gate forbids explicitly.Clause ② — the widening-tells reading, with its NOT MEASURED rows
node scripts/pm/check-widening-tells.mjs --declaration yes --diff …→ exit 0:yesthe gate examines no file. So the same diff was also run with--declaration noas a DIAGNOSTIC (⛔ not a declaration — the declaration isyes, and it is the seat's), to get the per-file reading:⇒ 17 NOT MEASURED rows, zero judged. ⛔ Not a clean reading, and reported as such: the gate's four tells cover Zod schema surfaces, closed sets, published export listings and registries, and this diff touches none of them.
--self-testpasses (510 cases).The declaration stands at
yeson the ground the seat's claim records — the observable behaviour at published runtime doors (rest-server.ts,objectqlcascade delete) changes — ⛔ and this PR does not review its own verdict.Changeset
One changeset,
minor× 5, with the level's reason stated rather than assumed.minorand notpatchbecause the declaration isClause-②: yesandcheck-changeset-no-major's level axis requires at least one moved published package gradedminoror above;minorand not a breaking grade because nothing conformant changes — a non-stringreferencecould not be authored, stored or parsed before this release either. That is the same grading and the same "Upgrading: nothing conformant changes" reasoning PR #18503's own changeset shipped for the same class of change, at the same launch-window convention.Docs — one page was falsified, and it was not on the bot's list
Added by the
domain:spec#3seat after the body's single dev write: this row did not exist when the body was written, and the dev does not PATCH a PR body.Docs Drift Check re-derived on the merge tree the bot names (
f3a03768de7b46fea7e8637158666569825c8f23, not the PR head), in a separate detached worktree so this branch never moved, reproducing the comment exactly: 28 docs, 7 release-owned, 21 hand-written, 9 anchors, 1 anchorless change, 38 package-mention pages.The 21 hand-written rows are clean, and the reason is worth stating: 20 of them came in through a single weak anchor — the literal
master_detail, which this diff only MOVED (the seed-loaderifwas rewritten) — and the eight symbol anchors (planCascadeAtomicity,cascadeDeleteRelations,masterDetailCount,masterOf,relationTarget,validateObjectReferences,walkObject,buildDependencyGraph) match no hand-written page at all. Each falsifiable statement in the 21 was re-read by hand and judged; none asserts the silence this change removes.The one page this change did falsify was invisible to that run, by the bot's own declaration:
packages/rest/src/rest-server.tsyields no doc anchor, so the page documenting that route could not appear on its list 「on this run or any run」. Found by a hand grep forLOOKUP_TARGET_MISSINGacrosscontent/docs:content/docs/ui/forms.mdx, whose public-form lookup picker error table read as covering both absence and unreadability — the exact distinction this routing exists to draw. Fixed here in two table cells: one clause on theobjectkey, and a new row for the500 INTERNAL_ERRORenvelope with the reason it is deliberately notLOOKUP_TARGET_MISSING. ⛔ Not broad doc rewriting.⛔ The 7 release-owned pages are read-only and byte-untouched (
git status content/docs/releases/empty). Each was checked for falsification and none is: six appear only via the weakmaster_detailliteral, andv17/17-1.mdxnamescascadeDeleteRelationsfor the registry read (#9002) rather than this carrier read. The generatedreferences/api/{contract,error-code-ledger}.mdxalso name the code but are auto-generated and this change adds no error code.Acceptance notes — noted, not filed (⛔ dev files no cards; these go to the seat)
The three-class findings above (a
String(f.reference)that invents[object Object]as a target name; the remaining C2 truthiness/equality readers,engine.ts:9033included; the arbiter message namingFieldSchemaat an ActionParam read site) are reported to the dispatching seat with dedup words, which runs dedup and files. Also noted and ⛔ not filed:{ reference: X.reference }wrapper form at the four lint sites and inverify/derive.tsis deliberate and is not stylistic drift: the literal.referenceread stays at the site so the validate-expressions / validate-security-posture 也有同形的 spec 不声明键的??别名读法(#5009 建议 3 的核对结果) #5017 receiver meta-test inpackages/lintkeeps its subject (it reads the rule's SOURCE to prove it readsreferenceand never an alias, and folding the read into a helper call disarms that scan silently — it went red on exactly that during this work), andrelationTarget's docblock makes the same key-spelling argument at length. The three runtime readers pass the field def directly, where the def is the natural argument and no such scan exists. Successor for the asymmetry: whoever routes the C2 population next.The open question this card carries — ⛔ not answered here
Whether arbiter + lint routing satisfied ruling item 2, or whether PR #18503 was owed the residue too, is recorded as the maintainer's to give. ⛔ This PR does not answer it and landing it is ⛔ not an answer. Two pieces of evidence bearing on it surfaced and are offered without a verdict: the ten residue lines were a measurable population at the time (each printed and read), which bears on whether they were reachable in that PR's scope; and six of the ten are the SAME code shape PR #18503 sorted into its deliberately-unchanged C1 class, so "the residue" and "the boundary" were not two populations but one, sorted twice.
Generated by Claude Code