Skip to content

Commit bdd3654

Browse files
fix(spec,lint,metadata-protocol): correct the view container name ledger note; delete the unreachable list-view tabs walks (#21423)
Fixes #20301 Clause-②: no Stage 2 of the card, re-scoped by claim amendment `5953063959` and its correction `5953104466`. Stage 1 (PR #20357) retired `list.tabs`. Stage 2 does not retire the view container's body `name`, because the A2 stop fired: the metadata door writes that key itself (os-dev report `5953000865`). What lands here is the corrected ledger record plus the carried notes from the stage-1 landing record `5870707479`. ⛔ No tombstone, no conversion, no door change, no `view.zod.ts` key change. #21412 (the runtime door accepts a contradicting container `name`) is not addressed here and remains open. ## What changed **1. `packages/spec/liveness/view.json`, the `name` row: the status stays `dead` and only the note is rewritten.** `verifiedAt` is now 2026-10-02. - **Why the verdict holds.** The ledger defines `live` as "authoring the property changes runtime behaviour". An authored container `name` either restates the key the container already registers under or contradicts it. So `os validate` / `os lint` keep their `liveness-dead-property` warning ("drop it"), which is still the right advice. - **What the note corrects.** It no longer says "a copy nobody reads", because the door writes and reads that copy. `saveMetaItem` runs `normalizeViewMetadata` ahead of the schema gate, and it stamps the save name onto every view body that has none, containers included. This is pinned by `view-container-runtime-expansion.test.ts`. The overlay paths then key on the stamped copy: `hydrateOverlayIntoRegistry` registers no body without a `name`, and `mergePackageAwareOverlay` slots a row by it. The ObjectQL boot loop also mints the derived key onto every stack container it registers. - **Why the key is kept, not retired.** A tombstone would refuse the platform's own saves. The 2026-09-03 ruling (PR #15319) refused direction 3. Triage's guard on this card forbids retiring a key the platform's own writer still sends. The note follows the ledger's "kept deliberately" precedent. - **The old attribution, corrected.** The note used to say artifact-shipped containers and the metadata-validation sweep send the key. They do not: what was read as theirs is the door's stamp. - The ledger README's `view` cell carried the same false sentence and is corrected the same way. The `view.list.tabs` row's note now records that the two walks below are deleted. - **Counts.** `gen:liveness-counts` printed `0 shard(s) rewritten, 0 pruned`, and the totals are unchanged: `988 live · 3 experimental · 1 live-elsewhere · 109 dead · 10 planned = 1111`. **2. The carried notes, at their re-measured locations.** - `packages/spec/src/system/i18n-resolver.ts`: a comment still called `ListViewSchema.tabs` a live carrier. It now names the tombstone and says `UserFiltersSchema.tabs` is the one carrier. This is a comment-only change. - `packages/lint/src/validate-list-view-field-refs.ts`: the `checkTabs(listView.tabs, …)` call is deleted. This rule is `input: 'parsed'` in the authoring-rule registry, and every list-view shape tombstones `tabs`, so the key could never reach the call. The `userFilters.tabs` walk stays. Header prose that named `tabs[].filter` / `tabs[].view` as walked positions is updated. - `packages/metadata-protocol/src/metadata-diagnostics.ts`: the `view?.tabs` read in `computeViewReferenceDiagnostics` is deleted. The write door refuses the key, and stored and artifact bodies have it stripped by the conversion replay (`applyConversionsToStoredItem` / `applyArtifactForwardConversions`) before they are served. A body that still carries it is badged by `computeMetadataDiagnostics` with the tombstone prescription. - **Fixtures.** The list-tabs fixtures in `packages/lint/src/validate-list-view-field-refs.test.ts` and `packages/objectql/src/metadata-diagnostics.test.ts` are deleted. The objectql case that asserted the deleted read now asserts the surviving `userFilters.tabs` read on the same unknown field. `HARD_CODED_FILTER_WALKS` drops `tabs.filter.field`. The stage-1 tree-scoped absence pin (`view-list-tabs-retirement.test.ts`) drops those two files from its self-expiring `RESIDUE`, as that set's own assertion requires. The CLI i18n entry stays. **3. Patch changesets:** `@objectstack/spec` (liveness/ is in its `files[]`), `@objectstack/lint` and `@objectstack/metadata-protocol`. Each carries `Clause-②: no`. objectql changes only a test file, and its `files[]` ships `dist` only, so it gets no changeset. ## Verification HEAD `7ee481ef2d` (base `6d67ad5eca`). Every `os-verify-lock` run below reports `VERDICT command-exit 0`. | Command | Result | |:--|:--| | `pnpm --filter '@objectstack/objectql^...' build` | spec, lint, metadata-protocol and the closure built, with declarations | | `pnpm --filter @objectstack/spec check:generated` | `✓ All 15 generated artifacts are up to date` | | `pnpm --filter @objectstack/spec typecheck` | exit 0 | | `pnpm --filter @objectstack/spec check:liveness` | exit 0, `✓ packages/spec/liveness/state-counts/ is current` | | `pnpm --filter @objectstack/spec test` | `Test Files 600 passed (600)`, `Tests 17601 passed / 1 todo` | | `pnpm --filter @objectstack/lint test && … typecheck` | `Test Files 119 passed`, `Tests 5574 passed`, test-typecheck OK | | `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 && … typecheck` | `Test Files 201 passed / 3 skipped`, `Tests 2983 passed / 19 skipped` | | objectql: three test files, `typecheck`, `build` | `metadata-diagnostics`, `view-container-divergent-name-registrars`, `metadata-validation-sweep`: `Tests 23 passed`; typecheck OK | **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` printed 91 commands. Each was run, and its exit code was recorded before any pipe. `--ran` printed `✓ dispatch-gates --ran: 91 derived famil(ies) accounted for — 90 run, 1 NOT-MEASURED`. That includes `check:adr-0087-registration` (`✓ … no declared-breaking changeset (3 non-breaking changeset(s) seen)`), `check-changeset-no-major` (`✓ This diff introduces no major bump`), `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:doc-authoring`, `check:nul-bytes`, `check:cross-package-test-inputs`, and every `@objectstack/spec` `check:*` the derivation named. Two gates did not measure anything locally: - `NOT MEASURED: check:dual-build-cjs-loads`. It exited 3 (`PREREQUISITE NOT MET`) because it needs every package built, which this run did not do. - `NOT MEASURED: check-engine-split-ratio --days 90`. It exited 2 with `cannot compute … this clone is shallow`. It is an ADR trigger metric over git history, and it is recorded as not measured rather than as a pass. `check:lean-entry-closure` first exited 3 for lack of objectql's `dist`. After objectql was built it exited 0 (`✓ … Admitted set held exactly`). **Narrowing, declared:** the branch is not merged with `origin/main`. `ceb4a939b4` is 7 commits ahead, and `git diff --stat 6d67ad5 ceb4a93` over this PR's 11 paths is empty. CI's merge ref judges the combination. ## Acceptance notes - objectui's `tabs?: ListViewSchema['tabs']` mirror (types `objectql.ts`) belongs to objectui. It picks up the stage-1 tombstone at its next pin bump, and this PR does not edit it. - `packages/spec/src/ui/view.zod.ts` (the `ViewSchema` guidance comment, about lines 4720-4728) still says artifact-shipped containers and the validation sweep send the container `name`. That is the same misattribution the ledger note corrects. The comment is left alone, per this stage's no-`view.zod.ts` scope. Carrier: the next PR to touch that block, or none. - `skills/**`: no hits for the touched surfaces. - The at-tier contract review follows this PR (seat's note on the claim amendment). --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 39a912e commit bdd3654

11 files changed

Lines changed: 88 additions & 59 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
The list-view field-reference rule no longer walks a list view's own `tabs[].filter`
6+
7+
Clause-②: no
8+
9+
The list view's own `tabs` is a `retiredKey` tombstone on every list-view shape, and this rule judges the parsed stack, so the key could never reach the walk: the parse refuses it first, with its prescription. The dead branch is deleted. The rule still judges `filter` and `userFilters.tabs[].filter` exactly as before.
10+
11+
No finding changes for any stack that `os validate`, `os lint` or `os build` accepts.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
`computeViewReferenceDiagnostics` no longer walks a list view's own `tabs[].filter`
6+
7+
Clause-②: no
8+
9+
The list view's own `tabs` is a `retiredKey` tombstone on every list-view shape. The write door refuses it, and a stored or artifact-shipped body has it stripped by the conversion replay before it is served, so the read could never see it. A served body that still carries it is already badged by the spec diagnostics (`computeMetadataDiagnostics`), with the tombstone's prescription. The `userFilters.tabs[].filter`, `filterableFields` and `kanban` checks are unchanged.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Liveness ledger: the view container's body `name` row stays `dead`, and its note now states what the platform actually does with the key
6+
7+
Clause-②: no
8+
9+
- The old note said the body copy was "a copy nobody reads". Measured, the metadata door stamps the save name into every saved view body that has none, containers included (`normalizeViewMetadata` in `@objectstack/metadata-protocol`). Its overlay paths key on that stamped copy: `hydrateOverlayIntoRegistry` registers no body without a `name`, and `mergePackageAwareOverlay` slots an overlay row by it.
10+
- The verdict is unchanged, because the ledger's `live` means that authoring the key changes runtime behaviour. An authored container `name` only restates the key the container already registers under, or contradicts it. `os validate` and `os lint` keep warning `liveness-dead-property` ("drop it").
11+
- The note records why the key is kept rather than tombstoned: the door's own saves stamp it, so a tombstone would refuse the platform's own writes. A maintainer ruling also refused a spec-level forbid of a container's `name`.
12+
- It corrects the old attribution too. Artifact-shipped containers and the metadata-validation sweep author no `name`; what was read as theirs is the door's stamp.
13+
- The ledger README's `view` cell says the same. The `view.list.tabs` row's note now records that the two author-time walks that still read a list view's own `tabs` are deleted.
14+
- A comment in `system/i18n-resolver.ts` that still called the list view's own `tabs` a live carrier now says the key is a tombstone and `UserFiltersSchema.tabs` is the one carrier.
15+
- ⛔ No schema, parse, export, status or accept-set change.

‎packages/lint/src/validate-list-view-field-refs.test.ts‎

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ const FULL_LIST_VIEW: AnyRec = {
8383
fields: [{ field: 'status' }],
8484
tabs: [{ name: 'open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }],
8585
},
86-
tabs: [{ name: 'mine', filter: [{ field: 'business_unit', operator: 'equals', value: 'x' }] }],
8786
kanban: { groupByField: 'status', summarizeField: 'estimate', columns: ['title'], titleField: 'title' },
8887
calendar: {
8988
startDateField: 'due_at',
@@ -241,23 +240,22 @@ function positionAsserted(path: string, walked: ReadonlySet<string>): string | u
241240
}
242241

243242
/**
244-
* [#18836] The rule's three hard-coded filter walks, spelled as
243+
* [#18836] The rule's two hard-coded filter walks, spelled as
245244
* {@link casePath} normalises the paths they report at.
246245
*
247246
* They are open code, not table rows — `checkListView` calls `checkFilter` on
248-
* `listView.filter`, on `tabs[]` and on `userFilters.tabs[]` — so the position
247+
* `listView.filter` and on `userFilters.tabs[]` — so the position
249248
* set derived from the rule cannot reach them, and the completeness assertion
250249
* below would let their rows be deleted in silence. That is precisely the hole
251250
* the floor this card replaced DID cover, by counting rows.
252251
*
253252
* So they are declared here and asserted EXACTLY: delete one of their rows and
254-
* the list comes up short; give a fourth hard-coded walk a row without adding
253+
* the list comes up short; give a third hard-coded walk a row without adding
255254
* it here and the list comes up long. Together with the derived assertion, every
256255
* row in both tables is then accounted for by one criterion or the other.
257256
*/
258257
const HARD_CODED_FILTER_WALKS = [
259258
'filter.field',
260-
'tabs.filter.field',
261259
'userFilters.tabs.filter.field',
262260
];
263261

@@ -292,11 +290,6 @@ describe('#14107 — every other walked position', () => {
292290
'views[0].list.userFilters.tabs[0].filter[0].field',
293291
'error',
294292
],
295-
[
296-
{ tabs: [{ name: 'a', filter: [{ field: BAD, operator: 'equals', value: 1 }] }] },
297-
'views[0].list.tabs[0].filter[0].field',
298-
'error',
299-
],
300293
[{ kanban: { summarizeField: BAD } }, 'views[0].list.kanban.summarizeField', 'warning'],
301294
[{ kanban: { columns: [BAD] } }, 'views[0].list.kanban.columns[0]', 'warning'],
302295
// [#18565] Calendar's level, not the required siblings' — see the row's
@@ -426,15 +419,15 @@ describe('#14107 — every other walked position', () => {
426419
// filter walks — nothing else. A set, compared in both directions, which
427420
// holds three things the completeness assertion does not:
428421
//
429-
// - SHORT ⇒ RED. Delete a filter-walk row and the set loses a member. All
430-
// three rows measured, one by one. That is exactly the coverage the
422+
// - SHORT ⇒ RED. Delete a filter-walk row and the set loses a member. Every
423+
// row measured, one by one. That is exactly the coverage the
431424
// row-counting floor had and the derived assertion cannot reach.
432-
// - LONG ⇒ RED, measured two ways. Remove one of the three declarations
425+
// - LONG ⇒ RED, measured two ways. Remove one of the declarations
433426
// below and the rows outnumber them. Leave a row behind for a position the
434427
// rule has DROPPED and it matches neither side, so it arrives here as an
435428
// extra — red here as well as in that row's own per-case assertion, which
436429
// is how this assertion ends up carrying the direction its sibling above
437-
// cannot. A fourth hard-coded walk given a row without being declared
430+
// cannot. A third hard-coded walk given a row without being declared
438431
// below lands in the same place by the same comparison.
439432
it('accounts for every asserted path, as a walked position or a declared filter walk', () => {
440433
const walkedSet = new Set(listViewWalkedPositions());
@@ -707,11 +700,10 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve
707700
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('id.x'))))).toEqual([]);
708701
});
709702

710-
it('the tab and user-filter tab presets are judged on the same axis', () => {
703+
it('the user-filter tab presets are judged on the same axis', () => {
711704
const findings = validateListViewFieldRefs(
712705
stackWith(
713706
mutate({
714-
tabs: [{ name: 'mine', filter: [{ field: 'owner.name', operator: 'equals', value: 'x' }] }],
715707
userFilters: {
716708
fields: [{ field: 'status' }],
717709
tabs: [{ name: 'open', filter: [{ field: 'parent.title', operator: 'equals', value: 'x' }] }],
@@ -720,7 +712,6 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve
720712
),
721713
);
722714
expect(idsOf(findings).sort()).toEqual([
723-
'views[0].list.tabs[0].filter[0].field',
724715
'views[0].list.userFilters.tabs[0].filter[0].field',
725716
]);
726717
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);

‎packages/lint/src/validate-list-view-field-refs.ts‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@
134134
* `INVALID_FIELD` / 400 (`packages/objectql/src/engine.ts`, #7589), and
135135
* `assertProjectionFieldsExist` answers the same at the REST ingress
136136
* (#7532). So every dotted column is reported, whatever its head's type.
137-
* - **Filter** — the view's own `filter`, its `tabs[].filter`, its
138-
* `userFilters.tabs[].filter`, and the two positions that DECLARE which
139-
* names the end user may filter on (`filterableFields`, spelled by the
137+
* - **Filter** — the view's own `filter`, its `userFilters.tabs[].filter`,
138+
* and the two positions that DECLARE which names the end user may
139+
* filter on (`filterableFields`, spelled by the
140140
* spec as "bare field names enabled for end-user filtering", and
141141
* `userFilters.fields`; objectui folds the resulting conditions into the
142142
* fetched query through `buildEffectiveFilter`). Here the door does NOT
@@ -201,9 +201,10 @@
201201
* owned by `validateActionNameRefs`.
202202
* - **`conditionalFormatting[].condition`** — a CEL predicate, owned by the
203203
* expression rules.
204-
* - **`tabs[].view` / `addRecord.formView`** — view names, owned by
205-
* `lintViewRefs`. (`pageName` was here too until #17063 retired the
206-
* `type: 'page'` view mount; a list view carries no page reference now.)
204+
* - **`addRecord.formView`** — a view name, owned by `lintViewRefs`.
205+
* (`pageName` was here too until #17063 retired the `type: 'page'` view
206+
* mount, and `tabs[].view` until the list view's own `tabs` was retired; a
207+
* list view carries neither reference now.)
207208
* - **The `data.object` binding itself** — `validateObjectReferences` owns
208209
* object-name reference sites, with the curated cross-package severity
209210
* ladder a local "not in this stack ⇒ error" would not have. When the bound
@@ -520,11 +521,11 @@ const COLUMN_ENTRY_POSITIONS: Array<{ block: string; key: string; severity: Sev
520521
* It names positions only — no severity, no shape — so reading it can never
521522
* stand in for reading the tables.
522523
*
523-
* The three filter walks further down (`listView.filter`, `tabs[].filter`,
524+
* The two filter walks further down (`listView.filter`,
524525
* `userFilters.tabs[].filter`) are deliberately absent: they are open code, not
525526
* table rows, so there is nothing HERE to derive them from. ⛔ Absent from this
526527
* list is not absent from the test's account of itself — the test declares those
527-
* three as an explicit list and asserts it exactly, because a row of theirs
528+
* two as an explicit list and asserts it exactly, because a row of theirs
528529
* deleted in silence is the one thing the row-counting floor this replaced did
529530
* cover.
530531
*/
@@ -768,12 +769,16 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin
768769
}
769770
}
770771

771-
// ── Filter KEYS: the view's own filter, its tabs' filters, and the tab
772-
// presets inside `userFilters`. `walkFilterFieldKeys` handles all three
773-
// authored filter shapes (Mongo condition object, `{ field, operator,
774-
// value }` rules, `[field, op, value]` triples) so a filter authored one
775-
// way is not judged while another is silently skipped (#3574's own
776-
// failure mode).
772+
// ── Filter KEYS: the view's own filter and the tab presets inside
773+
// `userFilters`. `walkFilterFieldKeys` handles all three authored filter
774+
// shapes (Mongo condition object, `{ field, operator, value }` rules,
775+
// `[field, op, value]` triples) so a filter authored one way is not judged
776+
// while another is silently skipped (#3574's own failure mode).
777+
//
778+
// The list view's OWN `tabs` is not walked: it is a `retiredKey` tombstone
779+
// on every list-view shape, and this rule judges the PARSED stack
780+
// (`input: 'parsed'` in the authoring-rule registry), where the key can
781+
// never arrive — the parse refuses it with its prescription first.
777782
const checkFilter = (filter: unknown, filterWhere: string, filterPath: string): void => {
778783
if (filter === undefined || filter === null) return;
779784
walkFilterFieldKeys(filter, filterPath, ({ field, path: at }) => {
@@ -792,7 +797,6 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin
792797
});
793798
};
794799

795-
checkTabs(listView.tabs, `${where} › tabs`, `${path}.tabs`);
796800
if (isRec(listView.userFilters)) {
797801
checkTabs(
798802
listView.userFilters.tabs,

‎packages/metadata-protocol/src/metadata-diagnostics.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,11 @@ export function computeViewReferenceDiagnostics(
228228
userFilters?.tabs?.forEach((t, i) =>
229229
t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`)));
230230

231-
(view?.tabs as Array<{ filter?: Array<{ field?: string }> }> | undefined)?.forEach((t, i) =>
232-
t?.filter?.forEach((r, j) => requireField(r?.field, `tabs.${i}.filter.${j}.field`)));
231+
// A list view's OWN `tabs` is not walked: it is a `retiredKey` tombstone on
232+
// every list-view shape. The write door refuses it, and a stored or
233+
// artifact-shipped body has it stripped by the conversion replay before it
234+
// is served. A body that still carries it is already badged by the spec
235+
// diagnostics, with the tombstone's prescription.
233236

234237
(view?.filterableFields as string[] | undefined)?.forEach((f, i) =>
235238
requireField(f, `filterableFields.${i}`));

‎packages/objectql/src/metadata-diagnostics.test.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
3030
fields: [{ field: 'industry' }, { field: 'is_active' }],
3131
tabs: [{ name: 't', filter: [{ field: 'status', operator: 'equals', value: 'x' }] }],
3232
},
33-
tabs: [{ name: 'a', filter: [{ field: 'industry', operator: 'equals', value: 'technology' }] }],
3433
filterableFields: ['status'],
3534
kanban: { groupByField: 'status', columns: ['name'] },
3635
}, objectDef);
@@ -48,12 +47,12 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
4847
});
4948
});
5049

51-
it('flags tab filter rules pointing at unknown fields', () => {
50+
it('flags user-filter tab preset rules pointing at unknown fields', () => {
5251
const result = computeViewReferenceDiagnostics({
53-
tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }],
52+
userFilters: { element: 'tabs', tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }] },
5453
}, objectDef);
5554
expect(result.valid).toBe(false);
56-
expect(result.errors?.[0].path).toBe('tabs.0.filter.0.field');
55+
expect(result.errors?.[0].path).toBe('userFilters.tabs.0.filter.0.field');
5756
});
5857

5958
it('flags kanban groupBy on a non-select-like field', () => {

0 commit comments

Comments
 (0)