Repository navigation
Commit 5636641
Fixes #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 #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 (#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 #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>
1 parent 4efb988 commit 5636641
3 files changed
Lines changed: 537 additions & 5 deletions
File tree
- .changeset
- packages/plugins/plugin-audit/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| |||
363 | 363 | | |
364 | 364 | | |
365 | 365 | | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
366 | 436 | | |
367 | 437 | | |
368 | 438 | | |
| |||
469 | 539 | | |
470 | 540 | | |
471 | 541 | | |
472 | | - | |
| 542 | + | |
473 | 543 | | |
474 | 544 | | |
475 | 545 | | |
| |||
524 | 594 | | |
525 | 595 | | |
526 | 596 | | |
527 | | - | |
| 597 | + | |
528 | 598 | | |
529 | 599 | | |
530 | 600 | | |
| |||
626 | 696 | | |
627 | 697 | | |
628 | 698 | | |
629 | | - | |
| 699 | + | |
630 | 700 | | |
631 | 701 | | |
632 | 702 | | |
| |||
690 | 760 | | |
691 | 761 | | |
692 | 762 | | |
693 | | - | |
| 763 | + | |
694 | 764 | | |
695 | 765 | | |
696 | 766 | | |
| |||
0 commit comments