Skip to content

fix(plugin-approvals): resolve a reference field's target through referenceTargetOf - #19263

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-19198-implicit-reference-lookup
Sep 20, 2026
Merged

huangyiirene merged 2 commits into
mainfrom
claude/issue-19198-implicit-reference-lookup

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #19198

Clause-②: no

The defect

ApprovalService.resolveLookupFields admitted type: 'user' fields but resolved their target from the materialized reference carrier. The spec declares the opposite for exactly that type — IMPLICIT_REFERENCE_TARGETS in packages/spec/src/data/field-value.zod.ts, re-read verbatim on origin/main at 7d0f911da before this branch was cut:

user is the only member: field.zod defines it as "a lookup specialized to the sys_user system object … target fixed to the sys_user system object", and the Field.user() builder — unlike Field.lookup(reference, …) / Field.masterDetail(reference, …) — takes NO target argument and writes reference: 'sys_user' itself. The target is a CONSTANT OF THE TYPE, so reference on a user field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is fully specified, not under-specified.

So the one spelling the contract calls complete was the one the reader refused — and it refused it silently: no refusal, no diagnostic, the field simply absent from payload_display, and a business reviewer reading a raw user id where every other reference field showed a name. referenceTargetOf lives in that same module precisely to supply the constant (Framework#4443 / cloud#983 fixed the identical blindness at the $expand gate and in the expansion engine).

The repair

resolveLookupFields now asks referenceTargetOf — the spec's single arbiter of what a reference field points at — instead of reading the carrier.

  • Nothing else widens. The admitted types are unchanged (lookup, master_detail, user). A lookup / master_detail whose author-chosen target is absent still names nothing, is still left out, and still issues no read; tree is deliberately not added (see acceptance notes).
  • The unreadable-carrier behaviour is unchanged. referenceTargetOf reads the carrier through referenceCarrierOf, so the TypeError is still raised for an object- or array-valued reference and is still caught per field, and the existing pins in src/lookup-field-reference-carrier.test.ts pass untouched. The warning line now names this reader in its own prefix, because the message the arbiter throws names itself.
  • No authoring change. Metadata that already spells reference: 'sys_user' resolves to the same target it always did. Making authors restate the constant was rejected on the card as the 消费端宽容 direction the charter rejects.

Tests — silence is the defect, so both halves are pinned

New src/implicit-reference-target-lookup.test.ts (5 cases):

  1. a { type: 'user' } field with no reference resolves to sys_user, and that value is asserted to equal referenceTargetOf({ type: 'user' }) — so a hand-copied sys_user literal in this package would not satisfy it;
  2. implicit and materialized spellings answer identically;
  3. an author-chosen target that names nothing is still left out, with no warning;
  4. the consumer-level pin: on an object whose ONLY reference field is the implicit-target one, the enrichment now issues exactly one sys_user read for the payload's id (toHaveLength(1), ids ['u7']) and produces payload_display = { assignee: 'Grace Hopper' }. The read is the half a happy-value assertion cannot see — before the repair that read was never issued at all;
  5. the opposite direction: a genuinely targetless lookup produces no read against any referenced object and leaves payload_display unset, so "the implicit target is enriched" cannot be read as "everything is enriched now".

Reverse verification (ablation), run from the committed state through scripts/ablation-replace.mjs — anchor hit 1 time, blob 703a308132 → b1fa2e3f0300 on disk, mutation replacing the arbiter call with the pre-repair carrier read:

Test Files  1 failed | 1 passed (2)
     Tests  3 failed | 6 passed (9)

Cases 1, 2 and 4 go red (expected undefined to deeply equal { key: 'owner', reference: 'sys_user' }; expected [] to have a length of 1 but got +0); cases 3 and 5 stay green because they are the controls, and the whole sibling carrier suite stays green — the mutation is narrow. Restored and proven restored: blob back to 703a308132, identical to HEAD, git diff HEAD empty.

Gates

Everything below was captured with the exit code read before any pipe, at aef74f0fa.

  • pnpm --filter @objectstack/plugin-approvals test — exit 0, 49 files / 780 tests passed.
  • pnpm --filter @objectstack/plugin-approvals typecheck — exit 0, all three legs this package's script names (tsc --noEmit, tsc --noEmit -p tsconfig.scripts.json, check:test-typecheck). The test-layer ledger is unmoved: 8 file(s) / 324 error(s) / 27 pinned signature(s) — the new test file adds none and takes no entry.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 61 families; all 61 were run and reconciled with --ran: "61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3)."
  • Six of those first answered exit 3 = PREREQUISITE NOT MET = NOT MEASURED (check:dts-closure, check:dual-build-cjs-loads, check:i18n, check:lean-entry-closure, check:sourcemap-no-sources-content, check:type-check-debt). Each prerequisite was cleared — the dependency closure, then turbo run build over all packages — and each re-run to exit 0. None is reported as a zero.
  • pnpm lint (eslint . --no-inline-config, the repo's only style authority) — exit 0 over the whole tree, so no narrowing is being claimed.
  • grep os-regen .gitattributes read on the spot: no path in this diff is merge-driver managed.

Acceptance notes

Noted, not filed — each states who would meet it:

  • tree is a reference type this enrichment still does not admit. REFERENCE_VALUE_TYPES has four members; resolveLookupFields carries three, and plugin-audit deliberately carries the same three (audit-writers.ts: "tree is NOT on the list: it carries no reference"). Admitting it would widen what the inbox resolves rather than repair what it silently dropped, which is not this card. Successor: whoever next touches the shared three-type vocabulary in either package.
  • The warning text moved. The reader's name is now in the approvals prefix rather than inherited from the arbiter's message, because referenceTargetOf takes no reader label. The existing assertion on it still passes unchanged.

Reported to the dispatching seat rather than filed here (dev agents report findings with dedupe words; they do not file): one class-(b) sibling of this defect in plugin-audit, and a reading that partly falsifies this card's own unverified note about the analytics resolver.

Not this PR

packages/spec is read-only on this lane — referenceTargetOf and IMPLICIT_REFERENCE_TARGETS are imported and nothing in them is edited. No release-notes page is touched.


Generated by Claude Code

…ferenceTargetOf`

`ApprovalService.resolveLookupFields` read the MATERIALIZED `reference`
carrier, so a `{ type: 'user' }` field authored without one was dropped from
inbox display enrichment — with no refusal and no diagnostic.

The spec declares that metadata complete: `IMPLICIT_REFERENCE_TARGETS`
(`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE
TYPE, so `reference` on a `user` field materializes that constant; it does not
supply it. Metadata authored without it … is fully specified, not
under-specified." Reading the carrier therefore refused the one spelling the
contract calls complete.

The reader now asks `referenceTargetOf` — the spec's single arbiter of what a
reference field points at, and the same function the expand gate and the
expansion engine ask (Framework#4443 / cloud#983). The unreadable-carrier
behaviour is unchanged: `referenceTargetOf` reads through `referenceCarrierOf`,
the throw is still caught per field, and the warning now names this reader
because the arbiter's message names itself.

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
…t reference target repair

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 2 documentable anchor(s).

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

  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 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 7d0f911da9ab9af94b72c0dfcfed16f676879bd5 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7d0f911da9ab9af94b72c0dfcfed16f676879bd5

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

@huangyiirene
huangyiirene marked this pull request as ready for review September 20, 2026 08:28
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 841a71e Sep 20, 2026
36 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-19198-implicit-reference-lookup branch September 20, 2026 08:56
huangyiirene pushed a commit that referenced this pull request Sep 20, 2026
… `referenceTargetOf` (#19264)

`audit-writers.ts` admitted `user` as a reference type and then required the
materialized `reference` carrier, so a `trackHistory`'d `{ type: 'user' }`
field authored without one was planned for nobody and the timeline rendered
the raw id — silently, with no refusal and no diagnostic.

`IMPLICIT_REFERENCE_TARGETS` declares the opposite for exactly that type: the
target is a CONSTANT OF THE TYPE, and metadata authored without it is fully
specified. `referenceTargetOf` exists to supply it, and is the same arbiter
`plugin-approvals` moved to in #19263.

The carrier was read at FOUR sites, not two: the two read planners key the
plan by target object and the two renderers look the resolved titles back up
under the same key, so a plan-only repair would issue the read and still print
the id. One helper answers for all four.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… `referenceTargetOf` (objectstack-ai#19264) (objectstack-ai#19285)

Fixes objectstack-ai#19264

Clause-②: no

## The defect

`packages/plugins/plugin-audit/src/audit-writers.ts` admits `user` as a
reference type (`REFERENCE_FIELD_TYPES`) and then required the
**materialized `reference` carrier**, so a `trackHistory`'d `{ type:
'user' }` field authored without an explicit `reference` was planned for
nobody and the timeline rendered the raw id.

The contract says the opposite for exactly that type.
`IMPLICIT_REFERENCE_TARGETS`
(`packages/spec/src/data/field-value.zod.ts`), re-read verbatim on
`origin/main` at `215840f43` before this branch was cut:

> `user` is the only member: `field.zod` defines it as "a lookup
specialized to the `sys_user` system object … target fixed to the
`sys_user` system object", and the `Field.user()` builder — unlike
`Field.lookup(reference, …)` / `Field.masterDetail(reference, …)` —
takes NO target argument and writes `reference: 'sys_user'` itself. The
target is a CONSTANT OF THE TYPE, so `reference` on a `user` field
materializes that constant; it does not supply it. Metadata authored
without it (hand-written JSON, an AI author, a Studio form) is fully
specified, not under-specified.

So the one spelling the contract calls **complete** was the one the
reader refused, and it refused it **silently**: no refusal, no
diagnostic, the field simply absent from the read plan.
`referenceTargetOf` lives in that same module to supply the constant,
and is the arbiter `plugin-approvals` moved to in PR objectstack-ai#19263 /
`841a71e51`.

## FOUR sites, not two — the card's site count is falsified

The card and the dispatch order both name **two** carrier-gated sites,
the two read planners. Re-measured on this branch's own base rather than
trusted, the carrier was read at **four**:

| line on `215840f43` | function | role |
|---|---|---|
| `:472` | `planTrackedLookupReads` | builds the read plan, **keyed by
target object** |
| `:527` | `renderTrackedChangeSummary` | looks the resolved titles back
up **under the same key** |
| `:629` | `planMilestoneTokenReads` | builds the milestone read plan,
keyed by target object |
| `:693` | `renderMilestoneSummary` | looks the resolved titles back up
under the same key |

The target is the KEY of the `objectName -> (id -> title)` map, so it
has two ends. Repairing only the two planners issues the read and then
looks the result up under a key the plan never used — the timeline still
shows the id, and now pays for a read it cannot use. That is measured
below, not argued: the renderer-only ablation leaves every read-count
assertion green and every rendered-string assertion red.

Everything else the card states holds as given: the line ranges
`:471-473` and `:628-630`, and the reading that `:434` in
`displayFieldValue` does not gate on the carrier and is the consumer
where the symptom surfaces rather than a defect site.

## The repair

One module-private helper, `referenceTargetForSummary`, asks
`referenceTargetOf`, and all four sites use it.

- **Nothing widens.** `REFERENCE_FIELD_TYPES` is untouched, so the
admitted types stay `lookup` / `master_detail` / `user`. `tree` is
deliberately still out: it carries an author-chosen target, so admitting
it would widen what the timeline tracks rather than repair what it
silently dropped. A `lookup` / `master_detail` whose author-chosen
target is absent still names nothing and still issues no read.
- **A padded carrier can no longer split the key.** The planners used to
`trim()` and the renderers did not, so `reference: ' crm_account '`
produced two keys and no title. One helper trims once, for both ends.
- **The unreadable-carrier behaviour is unchanged, and the throw is
caught deliberately.** `referenceTargetOf` reads the carrier through
`referenceCarrierOf`, which throws for an object- or array-valued
`reference`. All four call sites run inside `writeAudit`'s summary
composition, which is **not** inside the `try` that guards the audit row
write — measured: between `writeAudit`'s first line and its end there is
exactly one `try`, and it wraps `persistAuditTrailRow` alone. An
escaping `TypeError` would therefore leave the hook, turning a
display-enrichment miss into a failure on the audited write's own path.
Such a carrier is left out of the plan exactly as `typeof
field.reference === 'string'` already left it out: byte-identical
behaviour for that class, not a new silence.
- **No authoring change.** Metadata that already spells `reference:
'sys_user'` resolves to the same target it always did.

## Tests — silence is the defect, so the read and the rendering are
pinned separately

New
`packages/plugins/plugin-audit/src/implicit-reference-target-summary.test.ts`,
10 cases against the counting driver the sibling summary suites already
use (reads counted, rows copied).

Every implicit-target case asserts **both** halves: the `sys_user` read
delta (the half a happy-value assertion cannot see — before this change
that read was never issued at all) and the rendered summary string (the
half a read count cannot see — a plan-only repair pays for the read and
still prints the id). The target is asserted through
`referenceTargetOf({ type: 'user' })`, never a hand-copied `'sys_user'`,
so a second de-facto constant in this package would not satisfy it.

Both planner sites are covered: the tracked-change branch through a
tracked `user` field with no `reference`, and the milestone branch
through a milestone token on an **untracked** `user` field with no
`reference` (that branch is deliberately not gated on `trackHistory`).

Four narrowness controls, so "the implicit target resolves" cannot be
read as "everything resolves now": a tracked targetless `lookup` (as a
diff row and as a milestone token) stays unresolved and costs nothing; a
tracked `tree` with a perfectly readable target stays out of the type
gate and renders its raw id; and a write that moves no reference field,
on a row that HOLDS one, still pays zero reads.

The second commit is that file's `find` double, not the subject: see
Gates below.

### Reverse verification — two ablations, both through
`scripts/ablation-replace.mjs`

Re-run from the final committed state at `7ecf511d2`, after the test
file's last edit. The subject is resolved from SOURCE, not from `dist`:
the test imports `./audit-writers.js`, a relative specifier inside the
package, so vitest transforms the mutated file directly and no build
sits between the mutation and the run.

**Leg 1 — revert BOTH planner sites** (anchor hit 2 times as declared,
blob `9123759bc8e2` to `f6b9e89ac1a7`):

```
Test Files  1 failed (1)
     Tests  3 failed | 7 passed (10)
```

The three implicit-target cases go red on the READ: `expected +0 to be
1`, three times. The plan is empty again, exactly as before the repair.

**Leg 2 — revert BOTH renderer sites, leaving both planners repaired**
(anchor hit 2 times as declared, blob `9123759bc8e2` to `90516141da24`).
This is the leg that measures the card's two-site reading:

```
Test Files  1 failed (1)
     Tests  4 failed | 6 passed (10)
```

Every read-count assertion stays **green** and every rendered string
goes red — `expected 'Owner: ∅ → usr_1' to be 'Owner: ∅ → Grace Hopper'`
and `expected 'Deal won by usr_1' to be 'Deal won by Grace Hopper'`. The
reads are issued and thrown away. That is the reported symptom,
unrepaired, at a higher read cost.

Both legs restored and proven restored by the tool: blob back to
`9123759bc8e2`, identical to HEAD, `git diff HEAD` empty.

## The census triage asked for

> whoever takes either should sweep for a third: a contract that two
consumers read the same wrong way is a contract whose *consumers* need a
census, not two point fixes.

Corpus: every `referenceCarrierOf` and `referenceTargetOf` call site in
the repo's package sources, plus every raw `field.reference` read, at
`7ecf511d2`. **Positive control on the same corpus**:
`referenceTargetOf` is a real, importable symbol with call sites in
`metadata-protocol/src/protocol.ts`, `objectql/src/engine.ts`,
`objectql/src/integrity/dangling-reference-audit.ts`,
`objectql/src/record-title.ts`,
`plugin-approvals/src/approval-service.ts` and
`service-analytics/src/dimension-labels.ts` — so a zero below is a
reading.

A methodology note that is itself a finding. The first pass of this
sweep used the pathspec `packages/**/src/**/*.ts`, which under git's
default (non-`:(glob)`) matching **does not reach**
`packages/plugins/plugin-audit/src/audit-writers.ts` — the very file
this card is about. It returned a clean five hits. What caught it was a
control: grepping for a string the defect file is known to contain and
finding the file absent from the result. Every count below was re-taken
without that pathspec.

**One third site, same silent shape — reported, deliberately not fixed**
(outside this card's file surface):

- `packages/metadata-protocol/src/seed-loader.ts:712`,
`SeedLoader.buildDependencyGraph`. The type gate is `lookup ||
master_detail || user` — `user` admitted — and the next line reads the
carrier and `continue`s when it is absent. A seed row whose `{ type:
'user' }` field carries no `reference` therefore contributes **no
`dependsOn` edge** and is **never pushed onto `references`**, so its
natural key is written verbatim instead of being resolved to a record
id. That consequence is named in this same function's own docblock, for
the case it does handle: "every lookup / master_detail seed value was
written verbatim … Dangling references break parent joins." Silent,
user-visible, same misread contract. Dedupe words: `seed-loader user
implicit reference target` · `buildDependencyGraph referenceCarrierOf
user` · `seed natural key written verbatim user field` ·
`metadata-protocol implicit target census`.

**One near-site of a different shape — LOUD, reported for judgment:**

- `packages/rest/src/rest-server.ts:10914`, the public-form lookup
picker. It has **no type gate at all** (any field the form opts in with
`publicPicker`), reads the carrier, and answers `500
LOOKUP_TARGET_MISSING` when it is absent — so a `publicPicker` on a `{
type: 'user' }` field authored without `reference` is refused rather
than silently dropped. Same misreading, opposite failure mode.
`PUBLIC_FORM_SERVER_MANAGED_FIELDS` already refuses server-managed
anchors on this surface, so the reachable case is a hand-authored user
field. Dedupe words: `public form lookup picker user implicit target` ·
`LOOKUP_TARGET_MISSING user field` · `rest picker referenceCarrierOf
implicit`.

**The card's two unverified analytics readings — both CONFIRMED:**

- `service-analytics/src/dimension-labels.ts:142` already delegates to
`referenceTargetOf`. Correct today; no action.
- `service-analytics/src/plugin.ts:748` (`relationshipResolver`) gates
on `lookup || master_detail`, so `user` never enters the gate at all —
materialized carrier or not — and falls through to `return engine ?
undefined : relationshipName`. With an engine present that `undefined`
makes the dataset compiler reject the relationship. **LOUD**, and a
different shape: a type-gate exclusion, not an implicit-target misread.
No action.

**Cleared, each with its reason** — every other carrier consumer in the
corpus:

- Type gate excludes `user`, so the implicit target cannot arise:
`cli/src/commands/doctor.ts:726` and `:930` (`lookup` only),
`objectql/src/engine.ts:13113` and `:13568` (cascade; `master_detail` /
`lookup`), `lint/src/validate-expressions.ts:395` and
`lint/src/validate-sharing-rule-enforceability.ts:267` (`master_detail`
only), `verify/src/derive.ts:148` (its `RELATIONAL` set is the `lookup`
/ `master_detail` spellings plus `tree`), and
`spec/src/kernel/functional-completeness.ts:177` — whose
`RELATIONSHIP_TYPES` is `lookup` / `master_detail`, so
`field/relationship-without-reference` does **not** fire on a
spec-complete `user` field.
- Reads the carrier as the AUTHOR'S DECLARED KEY, which is their actual
subject rather than "what does this field point at": the five generic
lint readers (`data-model-rules.ts:250`, `object-graph.ts:242`,
`validate-field-consumers.ts:560`, `validate-object-references.ts:306`,
`validate-security-posture.ts:292`).

## Gates

Every exit code below was captured **before** any pipe. The derived set
was re-derived at the final head `7ecf511d2` and is byte-identical to
the one derived at `dce04112d`.

- `pnpm --filter @objectstack/plugin-audit test` — exit 0, **25 files /
363 tests passed**, at the final head.
- `pnpm --filter @objectstack/plugin-audit typecheck` — exit 0. The leg
count was read from the package, not assumed: its `typecheck` script is
`tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && pnpm
check:test-typecheck`, **three legs**, all run. The test-layer ledger is
unmoved — `0 file(s) / 0 error(s) / 0 pinned signature(s)` — and this
package carries no `test-typecheck-debt.json` of its own, so nothing was
touched there.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived **61 families** from a change set of **3 paths**
(three-dot semantics, merge base `215840f43`), **542 changed lines**,
under the 5,000 human-merge threshold. All 61 were run.
- **One family went genuinely RED and was repaired, not baselined.**
`pnpm check:objectql-double-limit` exit 1: the new test file's counting
`find` double was UNJUDGED, because the gate lifts the double and drives
it with a row stub in place of every non-function declaration, and
`(reads.findOn[object] ?? 0) + 1` throws on that stub before the body
can be judged. The double now reads its counter by TYPE and applies the
caller's `limit` by PRESENCE, after the filter and before the rows are
copied. Re-run: exit 0, and the census moved the right way — **393
graded (was 392), 195 applying the bound (was 194), 39 proving the bound
precedes a row-touching stage (was 38), 55 unjudged (was 56)**, with
`baseline key set verified against 215840f: no files added`.
- **Three families first answered exit 3 = PREREQUISITE NOT MET = NOT
MEASURED**: `check:dual-build-cjs-loads`, `check:i18n` and
`check:type-check-debt`. Each named the same prerequisite ("this gate
reads built output, and some package has no dist/ … Run `pnpm build`
first. ⛔ This is NOT a pass: nothing was measured"). The prerequisite
was cleared with a whole-tree `pnpm build` (exit 0, 73/73 tasks) and
each re-ran to **exit 0**. None is reported as a zero.
- The remaining **57** families ran to exit 0 on the first pass. After
the second commit, every family whose subject is the changed test file
was re-run at the final head — `check:objectql-double-limit`,
`check:type-check-debt`, `check:type-check-coverage`,
`check:test-source-alias`, `check:cross-package-test-inputs`,
`check:nul-bytes`, `check:tier-file-adoption`,
`check:engine-double-contract`, `check:driver-memory-census`,
`check:where-matcher`, `check:published-files`, `check-empty-changeset`,
`check-changeset-no-major`, `check-adr-0087-registration`,
`check-closing-keyword-parity` — all **exit 0**.
- `pnpm lint` (`eslint . --no-inline-config`, the repo's only style
authority) — **exit 0 over the whole tree**, so no narrowing is being
claimed and no narrowing evidence is owed.
- `grep os-regen .gitattributes`, read on the spot: every
merge-driver-managed path is under `packages/spec/`, `docs/`,
`content/docs/`, `skills/` or
`scripts/platform-object-tenancy-census.json`. **No path in this diff is
driver-managed.**
- A control-byte self-scan beyond `check:nul-bytes`, over all three
changed files: `grep -naP` for the C0 range plus DEL — no hits.
- Changeset: decided by reading, not habit.
`packages/plugins/plugin-audit/package.json` carries no `private` key
and ships `files: ["dist", "README.md", "CHANGELOG.md"]`, so a fix here
publishes. A `patch` changeset is included.

### Verdict lines transcribed verbatim, because those logs die with the
worktree

From `pnpm check:engine-double-contract` (exit 0):

```
UNRECOGNISED [engine-double-contract]: 9 construct(s) in packages, examples declare a scanned verb (delete, update, findOne) alongside engine siblings, and this gate could not read the implementation -- so they are in NEITHER the pinned population nor the ledger. 213 further construct(s) are SCOPED OUT by a stated criterion and are not counted here. This is a verdict, not a finding: it never fails a run (objectstack-ai#9747, ruling of 2026-08-18).
```

From `node scripts/check-changeset-no-major.mjs --base origin/main`
(exit 0):

```
ℹ️ LEVEL AXIS: NOT APPLICABLE — this run has no `pull_request` to read a declaration from, so the clause-② axis has no input by construction rather than by omission. It is a PR-scoped reading: the RC cut (`cut-rc.yml`) and a local run reach here, and neither is a PR that could have declared.
   · no `pull_request` payload was available to read a declaration from
```

That second one is a local-run artefact by construction: on this PR the
axis has its input, and the declaration it will read is the `Clause-②:
no` line at the top of this body.

## Not this PR

`packages/spec` is read-only on this lane: `referenceTargetOf` is
imported and nothing in that package is edited. No release-notes page is
touched. `plugin-approvals` was repaired in PR objectstack-ai#19263 and is out of
scope here. The two census findings above are reported for the seat to
file and deliberately **not** fixed here — that is the difference
between two bugs and one systemic gap.

## Acceptance notes

- `noted, not filed` — the sibling summary suites
(`audit-lookup-summary.test.ts`, `audit-milestone-summary.test.ts`,
`audit-option-label-summary.test.ts`, `audit-bound-previous.test.ts`)
each hold the same counting driver in its pre-repair, UNJUDGED shape and
are grandfathered in `scripts/objectql-double-limit.baseline.json`. The
one-line change made here would shrink that ledger by four entries. Not
done in this PR: it is unrelated to the card and would put four files
this card has no business in on the diff. Carrier: any later PR editing
those suites.
- `noted, not filed` — `renderTrackedChangeSummary` and
`renderMilestoneSummary` both build an `optionLabelFor` closure per
field per write. Ordinary allocation on a hot path; no defect, no repro,
no contract text. Carrier: none — no open PR or queued card touches
those two functions.
- `noted, not filed` — `referenceTargetOf` answers a whitespace-only
carrier (`reference: ' '`) with that whitespace rather than falling
through to the type's constant, because `referenceCarrierOf` treats only
the empty string as absence. The helper added here trims afterwards, so
the audit path's behaviour is unchanged in both directions; making the
arbiter itself trim is a `packages/spec` question and this lane is
read-only there. Carrier: none observed.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants