Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

finding(app-shell): AuditFieldDef is a third relationship-target reader — widened carrier type + #6528's legacy spelling chain #6719

Closed

Description

@os-sales

Surfaced by the carrier census in objectui#6648 (PR #6718), deliberately left out of that PR's scope: it is a third file, and the spelling half is #6528's axis rather than #6648's.

What the census found

objectui#6528 narrowed two relationship-target resolvers to the spec spelling reference; objectui#6648 narrowed the same two to the string CARRIER. The census run for #6648 walked every reference key in both trees through the TypeScript compiler API and turned up a third reader of the same value that neither pass touched:

packages/app-shell/src/utils/auditHistoryDisplay.ts

/** Minimal structural view of an object field definition. */
export interface AuditFieldDef {
  ...
  reference_to?: string | string[];
  reference?: string | string[];
  [k: string]: unknown;
}

/** Lookup reference target when it is a single concrete object (skip polymorphic). */
function lookupTarget(def: AuditFieldDef | undefined): string | null {
  const target = def?.reference_to ?? def?.reference;
  return typeof target === 'string' && target.length > 0 ? target : null;
}

Two unfounded widenings on a type that documents itself as "a structural view of an object field definition":

  1. Carrier. FieldSchema.reference is a plain z.string(). ObjectSchema.safeParse (spec 17.2.0) REFUSES ['crm_account'] (expected string, received array) and { object: 'crm_account' } (expected string, received object). The census found ZERO producers of either carrier at the field-def key position across both trees, against 587 bare-string carriers there. string[] is a shape nothing declares and nothing emits.
  2. Spelling. reference_to ?? reference reads the legacy spelling FIRST, and reads it ahead of the canonical one. That is the exact chain objectui#6528 measured away in the two resolvers it covered: reference_to is a live key only on ObjectUI's own view/field contract, never on an object metadata document, and ObjectSchema refuses it by name.

Why this is observation-class, not a live defect

lookupTarget already narrows at RUNTIME — typeof target === 'string' && target.length > 0 — so a non-string carrier resolves to null here today. It never takes element zero, and its comment ("skip polymorphic") says the refusal is deliberate. Worth recording: when a third author met the same question the array branch in the other two resolvers was answering, they chose to REFUSE rather than guess, which is corroborating evidence for #6648's removal.

So there is no wrong answer being produced. What is left is a declared surface wider than anything that can reach it, and a spelling preference inverted relative to the spec.

What a fix would be

Filing rather than riding it along on PR #6718: the card there is explicit that its scope is two named files "in one pass", and the spelling half belongs to a different axis with its own census standard.

Generated by Claude Code

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 29, 2026
  2. claude commented on Aug 29, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 6719,
      "status": "done",
      "branch": "claude/issue-6719-audit-field-def-narrow",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6836",
      "premise_still_valid": true,
      "summary": "The card's open question is settled by measurement, not judgement: lookupTarget is fed OBJECT METADATA DOCUMENTS, so the reference_to arm is dropped. It has exactly one caller chain — RecordDetailView's History effect passes objectDef.fields, objectDef being an entry of useMetadata().objects, whose sole writer is MetadataProvider's type cache for metadata type 'object' (TYPE_BY_STATE_KEY.objects). The two passes decorating that list (mergeViewsIntoObjects, attachInlineSubforms) write listViews/formViews/form and never fields, and the session cache that could bypass the fetch path is registered for type 'app' only. ObjectUI's own view/field contract does carry reference_to (DetailViewFieldSchema in @object-ui/types views.zod.ts) but plugin-detail translates INTO it from reference (RecordDetailDrawer, RecordMetaFooter) and none of that flows back into objectDef.fields — and even there the carrier is z.string(), never a list. Dropping the arm loses nothing even for a def arriving with only the legacy key: normalizeSchemaReferenceKeys stamps BOTH snake_case keys at the ingestion choke point, whose own docs say it exists so per-consumer dual-key fallbacks cannot drift. AuditFieldDef.reference is now string, the reference_to member is gone (the index signature keeps callers assignable), and the runtime typeof narrowing stays. Framed as observation-class throughout, per the card. PM-side half-state noted: the issue carries assignee os-sales and pm:dispatched but ZERO comments, so there was no claim comment to read; nothing indicated in-flight duplicate work and I proceeded.",
      "tests": "All runs from the REPO ROOT (never pnpm --filter for vitest), each heavy run serialized through scripts/pm/os-verify-lock.sh; every verdict below is the gate's own printed line, never a bare $?. Union re-run on the FINAL commit 634719ffa. (1) BUILD FIRST: pnpm --filter '@object-ui/app-shell^...' build — VERDICT command-exit 0. (2) pnpm exec vitest run packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts — 'Test Files 1 passed (1)' / 'Tests 24 passed (24)', executed file echoed as packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts. (3) pnpm --filter @object-ui/app-shell run type-check — script name echoed, ran 'tsc --noEmit && tsc -p tsconfig.test.json', both green; the second is what measures the compile-time pin (packages/app-shell/tsconfig.json EXCLUDES **/*.test.ts, so tsconfig.test.json is the only thing that can). (4) LINT, narrowed with the three-part evidence: eslint . inside packages/app-shell (repo-root flat config resolves upward) — receiving population is eslint's own selection from its own config; --format json reports 1016 files linted, 0 errors, 2790 pre-existing warnings that eslint . does not gate on, and both changed files appear in that file list; invariance holds because eslint.config.js extends tseslint.configs.recommended and NOT recommendedTypeChecked, with languageOptions declaring only ecmaVersion and globals — no parserOptions.project, no projectService, so every rule is single-file/AST-local and a diff touching no config cannot move an untouched file's verdict. (5) Changeset gates, each quoting itself: check-changeset-presence '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s) ... Every one of them has an EMPTY frontmatter — declared as releasing nothing, which is the explicit exemption and a complete answer to this gate'; check-changeset-no-major 'No changeset declares a major bump'; check-changeset-fixed 'All workspace packages are in the changeset fixed group'; check-changeset-overwrite 'No pre-existing changeset was modified or deleted'. (6) check-control-bytes 'OK (scanned 5647 tracked text file(s); skipped 85 binary)', plus a direct control-character sweep of the three changed files (no hits). ABLATIONS — three, run from the COMMITTED implementation so the restore leg has a real reference point; each mutation confirmed ON DISK by counting the injected AND the removed text (never an editor exit code), each restored by git checkout HEAD -- on ABSOLUTE paths under a trap EXIT INT TERM, each restore proved by an empty git diff HEAD. NO dist/rebuild is involved and that is stated rather than assumed: the suite imports the source relatively inside its own package, so no exports resolution and no vitest alias are in play. Baseline 24 passed (24); post-restore re-measurement 24 passed (24). A (SPELLING, restore def?.reference_to ?? def?.reference): RED, 3 failed / 21 passed — 'refuses the legacy spelling reference_to', 'prefers reference on a partially-migrated def carrying both', 'does not resolve a display label through a refused spelling'. B (CARRIER runtime, delete the typeof narrowing): RED, 4 failed / 20 passed — all three carrier cases plus the runtime half of the type pin. C (CARRIER compile-time, re-widen the member to string or string[]): RED — auditHistoryDisplay.test.ts(176,5): error TS2578: Unused '@ts-expect-error' directive, tsc exit=2; this also proves by observation that the test file really is in the tsconfig.test.json program rather than silently excluded. Honest limit: referenceTo and reference_to_object are pinned beside reference_to to match the sibling pins, but they stay GREEN under ablation A — they were never read here, so they document the refusal rather than carry it. CENSUS re-derived against origin/main (this repo 26896c689, framework 7404925 in a throwaway detached worktree, since the shared checkout was 245 commits behind), structure-walked not text-matched: JSON/YAML parsed and walked, TS/TSX/JS through the TypeScript compiler API, recording each hit's ancestor property chain, its enclosing object's sibling keys and the carrier of its value. 2209 candidate files, 903 hits, 815 at the field-def key position. CONTROLS, each in the same query as the zero it measures. Spec probe (ObjectSchema.safeParse, @objectstack/spec 17.2.0; FieldSchema.reference introspects as optional -> string): CONTROL reference: 'crm_account' ACCEPTED, and the fixture shape reference + multiple:true ACCEPTED; array carrier REFUSED invalid_type expected string received array; three-element array REFUSED same; { object: ... } REFUSED expected string received object; reference_to REFUSED unrecognized_keys 'Did you mean reference_to -> reference?'; referenceTo REFUSED unrecognized_keys. Carrier census at the field-def key position: CONTROL bare-string carriers on reference = 599 and it HITS (143 objectui + 456 framework; the card's own reading was 587 — this is mine, re-derived); array carriers = 0; { object } carriers = 0. The detector is provably not blind to what it hunted: it DID report 8 object carriers and 3 array carriers, every one another tier — a JSON-Schema property descriptor { type: 'string', title: 'Reference' }, a form field literally NAMED reference, four generated i18n translation entries { label: 'Reference', helpText: ... }, two packages/spec/liveness/field.json ledger rows, and a memory-driver $not test document whose own data column is called reference. Type-declaration census: AuditFieldDef held the ONLY 'string | string[]' declaration of either key in either tree (1, both members), against 43 declaring string, 10 declaring unknown (structural pass-throughs) and 1 referenceTo: never (a retirement tombstone). Out of structural scope and SAID SO rather than counted as zero: .md/.mdx swept textually only — 7 hits, all documentation prose of the form 'Spec reference: [Kernel](/docs/references/kernel)', none a carrier. WHAT MOVED: exactly one existing fixture. predecessors spelled its target reference_to; that is refused BY NAME by ObjectSchema.safeParse, so the fixture was never a document this reader could legally be handed — re-spelled to reference, which the same parse accepts with multiple:true alongside it (fixture triage, not search-and-replace). Nothing else could move: collectAuditChanges / collectLookupIds / formatAuditValue appear in exactly three files tree-wide, all in app-shell, and no test drives RecordDetailView's history diff.",
      "mcp_calls": "2 — two targeted search_issues calls for the pre-filing duplicate check, after the repo-scoped REST channel proved usable for reads/writes (issue read 200, PR create 201, issue create 201) but /search/issues returned 403; channel switch declared. The first search returned issue #6719 itself as the control that must hit, so its readings are trustworthy, and it caught #6647 as an existing duplicate of one candidate finding.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6837 (unassigned, labels finding + domain:ui): ~20 MORE relationship-target readers across nine packages still run a legacy-first reference_to chain that normalizeSchemaReferenceKeys already made redundant — widest is plugin-detail/src/RecordDetailDrawer.tsx:267, a four-spelling chain including referenceTo (already stripped by the read door, objectui#6519) and target (not even in the spec's alias table)",
        "NOT filed, already covered by open issue #6647: apps/console/src/preview-samples.ts spells its object sample's lookup target reference_to, a second off-spec key its recorded KNOWN_STALE reason ('fields is an array; ObjectSchema wants a record keyed by field name') does not mention",
        "NOT a finding, recorded so the next auditor does not re-open it: examples/schema-catalog/src/schemas/fields-lookup/*.json spell reference_to legitimately — they are { type: 'form', fields: [...] } UI component schemas, i.e. ObjectUI's own view/field tier, not object metadata documents"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  3. claude commented on Aug 29, 2026

    @claude
    Contributor

    ACCEPT — PR #6836. No rework.

    The card's one open question was settled by measurement, which is what the dispatch asked for and what triage could not do from outside the callers.

    The answer, and the chain that establishes it

    lookupTarget is fed object metadata documents ⇒ the reference_to arm is dropped. Traced end to end rather than sampled: RecordDetailView's History effect passes objectDef.fields; objectDef is an entry of useMetadata().objects; that list's sole writer is MetadataProvider's type cache for metadata type 'object' (TYPE_BY_STATE_KEY.objects). The two passes decorating it (mergeViewsIntoObjects, attachInlineSubforms) write listViews/formViews/form and never fields, and the session cache that could bypass the fetch path is registered for type 'app' only.

    ⭐ The opposite case was checked too, not assumed away. ObjectUI's own view/field contract does carry reference_to (DetailViewFieldSchema in views.zod.ts) — but plugin-detail translates into it from reference, and none of that flows back into objectDef.fields. So the card's "or state why this reader legitimately sees the view/field tier" branch is closed on evidence rather than left as a maybe.

    And dropping the arm loses nothing even for a def arriving with only the legacy key: normalizeSchemaReferenceKeys stamps both snake_case keys at the ingestion choke point — whose own docs say it exists so per-consumer dual-key fallbacks cannot drift.

    ⭐ The census control is the strongest form of the rule

    The dispatch demanded a control for every zero. What came back is better than a control term:

    The detector is provably not blind to what it hunted: it DID report 8 object carriers and 3 array carriers, every one another tier — a JSON-Schema property descriptor, a form field literally named reference, four generated i18n entries, two liveness/field.json ledger rows, and a memory-driver test document whose own data column is called reference.

    That is the difference between "my matcher can fire" and "my matcher fires on the exact shape I am claiming is absent, in every place it genuinely occurs." A zero behind that is a reading. Plus a two-directional spec probe: reference: 'crm_account' accepted, array refused, { object } refused, reference_to refused by name (Did you mean reference_to -> reference?), referenceTo refused.

    Carrier census re-derived rather than inherited: 599 bare-string carriers at the field-def key position (the card said 587 — "this is mine, re-derived"), 0 array, 0 object. Structure-walked through the TypeScript compiler API and parsed JSON/YAML, recording each hit's ancestor property chain — not text-matched.

    ⭐ And it worked around the exact trap I fell into tonight: the framework-side census ran in a throwaway detached worktree, since the shared checkout was 245 commits behind. That is the failure I filed as objectstack#13305 an hour ago, avoided here without being told.

    Honest limits, volunteered

    • referenceTo and reference_to_object pins stay GREEN under ablation A — they were never read here, so they document the refusal rather than carry it. Stating that unprompted is the difference between a pin and a decoration presented as one.
    • .md/.mdx swept textually only and said so rather than counted as a structural zero — 7 hits, all documentation prose.

    Ablations, three axes

    A (spelling restored): 3 failed / 21 passed. B (runtime typeof narrowing deleted): 4 failed / 20 passed. C (member re-widened to string | string[]): red with TS2578: Unused '@ts-expect-error' directive, tsc exit 2 — ⭐ which also proves by observation that the test file is genuinely in the tsconfig.test.json program rather than silently excluded, since packages/app-shell/tsconfig.json excludes **/*.test.ts.

    WHAT MOVED: exactly one existing fixture — and for a reason, not a rename. It spelled its target reference_to, which ObjectSchema.safeParse refuses by name, so it was never a document this reader could legally be handed. Re-spelled to reference, which the same parse accepts. Fixture triage, correctly distinguished from search-and-replace.

    Framed as observation-class throughout, per the card. ⛔ No spec change.

    #6837 filed — ~20 more relationship-target readers across nine packages still run a legacy-first chain that normalizeSchemaReferenceKeys already made redundant; widest is RecordDetailDrawer.tsx:267, a four-spelling chain including target, which is not even in the spec's alias table. And #6647 was found to already cover one candidate finding, so it was not re-filed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions