Skip to content

test(cli): os explain's key-retention sweep now judges the optional/required TABLE faces - #18538

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-17266-explain-key-retention-table-faces
Sep 16, 2026
Merged

os-support-ai merged 2 commits into
mainfrom
claude/issue-17266-explain-key-retention-table-faces

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #17266

Clause-②: no

What this is

A test-only pin. There is no defect in the tree today — the card says so itself (⛔ "Not a defect in what #16925 shipped — a declared gap in the guard it built") — and that was re-measured here rather than taken on trust: every row on all nine bound entries names a real key. So this lands green.

The key-retention sweep in packages/cli/test/commands.test.ts reads the EXAMPLE face only, because evaluate(key) is the one face it reads. A catalog entry names keys on a second face — its required / optional tables — and no assertion in that file had ever read a row's name against a schema. (The #3244 pin above it reads one row's type STRING; it never asks whether the row's NAME is a key.) The trap is the same one a step earlier: a reader copies a row, the schema silently strips the key, and the query runs unfiltered under an ordinary success. os explain query shipped filters / sort on BOTH faces; only the example half was guarded.

The taker was the file itself. The gap was declared in the test's own header, so that header is rewritten in the same diff — it no longer announces a gap that is closed, and it now points at the block that closes it.

The row grammar — the actual design work

A row's name is prose until something decides it is a key name. The declared rule:

A row name is read as an ALTERNATION — split on the pipe character, trim — and the row is judged iff every part is a bare identifier.

That is deliberately wider than "one identifier", and the width is what earns its keep: it makes view's required row (list | form | listViews | formViews, four slot names in the name position — the card's own blocking example) judgeable as four keys instead of waved through as prose. Measured: all four are real ViewSchema keys.

A row that does not fit the grammar is ⛔ not silently skipped — it must be declared in PROSE_ROWS, and a declaration that stops being needed fails too, so a stale one cannot go on un-judging a row. PROSE_ROWS is empty today, measured: every row on all nine bound entries fits.

Two techniques, because one entry does not read

technique entries reading
.shape 8 of 9 the schema's own declared key set
behavioural probe action plant the row's key on the entry's parsing example with a sentinel and read the verdict

ActionSchema resolves to a pipe (lazySchema(() = the arrow actionObject().refine(...))), and a pipe has no shape — the card predicted this and it holds. The probe never asks whether the VALUE is acceptable, only whether the schema KNOWS the name: an unrecognized_keys issue naming it is ABSENT (a strict object refused it), success with the key gone from the output is ABSENT (an open object stripped it silently), anything else is DECLARED.

Measured across all nine entries: the two techniques agree on every row the shape one can see, and the probe returns ABSENT for 7 control names on all 9 entries — including query, whose open top level answers by DROPPING rather than refusing, which is the exact silent-strip mode this guard exists for.

⭐ Each entry's test carries an inline control key that must read ABSENT. That is load-bearing rather than decorative: action has no second opinion, so a technique that answered "declared" to everything would report green over twelve rows and be indistinguishable from a real pass.

Premises verified before writing any code

  1. "The table faces are unguarded today." ✅ Located from its SYMBOL, never a line number. The only read of a table face in the whole file is SCHEMAS.object.optional.find(...) in the explain: os explain object documents ownership as "own" | "extend" — real values are user | org | none #3244 pin, which asserts the row's type string. Control words known present in the same file: evaluate( × 6, .example × 4.
  2. "packages/cli/test/commands.test.ts is the right and only place." ✅ It is the only file in the repository that imports the explain catalog at all (repo-wide grep for commands/explain, excluding node_modules and dist). No sibling pin exists.
  3. "No table row is wrong today." ✅ Confirmed by both techniques independently, on all 9 bound entries, every row, every alternation part. Zero disagreement, zero offenders. The two UNBOUND redirect entries carry zero rows.

Ablation — a pin that fails on the wrong fix

Two legs, both run from the committed state, both mutating the source the pin reads (packages/cli/src/commands/explain.ts), each with a trap restore on absolute paths.

HEAD blob for that file: 09ab4c720ef0a52ffccbf9c19100a307d16568ef

Leg 1 — query, the shape technique, silent-drop mode. Table row where rewritten to filters (the historical defect, on the table face only — the example keeps where).

  • occurrence counts on disk: before from=1 to=0, after from=0 to=1
  • mutated blob 535ff0bf015b9a757d2a89dabb666cfc3fae7a2f (not equal to HEAD blob)
  • vitest exit 1 — os explain query — every QuerySchema table row names a key the schema declares ✗, Tests 1 failed | 49 passed (50)
  • ⭐ os explain query — example survives QuerySchema with every declared key intact stayed GREEN. That is the card's claim made mechanical: the pre-existing retention assertion cannot see this face.
  • restored blob 09ab4c72... equals the HEAD blob; git diff HEAD on the path EMPTY; git status --porcelain empty

Leg 2 — action, the probe technique, refuse-by-name mode. Required row target rewritten to flow (the key the entry's own description says does not exist).

  • occurrence counts on disk: before from=1 to=0, after from=0 to=1
  • mutated blob 279748e6d171a66640fd020e37a24a8934e97ab7 (not equal to HEAD blob)
  • vitest exit 1 — os explain action — every ActionSchema table row names a key the schema declares ✗, Tests 1 failed | 49 passed (50); both the parse and retention assertions on action stayed green
  • restored blob equals the HEAD blob; git diff HEAD on the path EMPTY; porcelain empty

Final tree state after both legs: git diff HEAD exit 0 (clean). ⛔ No permanent ablation artefact is left behind.

No dist leg is owed here: the mutated subject is imported by the test relatively (from '../src/commands/explain'), so it resolves from source, not through a package exports entry — there is no built artefact in the resolution path for it.

Verification

Run on 4fb225c6e (the tip this PR opens with).

Gate derivation — node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, derived from the actual changed path (1 path, packages/cli/test/commands.test.ts), re-derived after the final commit and byte-identical. Reconciled with --ran carrying exit codes:

Run reconciliation — 49 derived, 48 run, 1 NOT-MEASURED, 0 UNRUN.

Beyond the derivation:

check result
pnpm lint (repo-wide, eslint . --no-inline-config) exit 0
pnpm --filter @objectstack/cli typecheck exit 0 (tsc --noEmit + check:test-typecheck)
pnpm --filter @objectstack/cli exec vitest run --project unit (the tier this file runs in) 211 files / 3013 tests passed
the edited file alone, verbose 50 passed (40 before, 10 new: 9 per-entry + 1 UNBOUND completeness)

⭐ Typecheck coverage of the edited file asserted rather than assumed: tsc -p tsconfig.test.json --listFiles names test/commands.test.ts (the package's plain tsconfig.json does not, which is why the typecheck script chains check:test-typecheck). Raw tsc -p tsconfig.test.json reports 28 errors across 3 OTHER files, all ledgered and shrink-only; zero are in test/commands.test.ts.

The integration tier is declared to CI: this diff touches no integration-layer file, no spawn entry point (bin/, test/helpers/serve-process.ts) and no driver/kernel boot path, and commands.test.ts lands in unit under packages/cli/vitest-tiers.ts (neither SPAWN nor KERNEL).

Changeset — measured, not assumed

skip-changeset is the correct disposition, and it was measured rather than reasoned from the path:

  • @objectstack/cli's files[] is ["dist", "README.md", "CHANGELOG.md"]; npm pack --dry-run ships dist/, bin/, README.md, CHANGELOG.md, LICENSE, package.json — no test source.
  • Symbol grep over the shipped paths, with a positive control: SCHEMAS (from src/commands/explain.ts) IS found in dist/commands/explain.js, so the grep can find a shipped symbol. Each of PROSE_ROWS, os_explain_table_face_control_key, __os_explain_table_face_probe__, rowKeyNames -> 0 shipped files.
  • A diff that touches no src/ cannot move dist.

⚠️ skip-changeset is OWED on this PR and the author was fenced from writing labels by its dispatch. changeset-check in pr-automation.yml has no path-based exemption — it requires either a changeset or that label — so this PR goes red until a seat applies it. Flagged here and in the report rather than applied.

Acceptance notes

Out of scope, noted and ⛔ not filed:

  • os explain query's optional table lists 6 of QuerySchema's 17 keys. The card declares missing rows out of scope by name and explains why (a missing row is an omission, not an error — nothing an author copies fails or is silently dropped). This PR's grammar judges rows that are PRESENT; it deliberately says nothing about absent ones. Taker: none — stated so the boundary stays legible.
  • view's prose spells filters in a line comment and filter in the entry description. A code-comment inconsistency with no runtime or authoring reach; the card routes it to [finding] os explain view's example teaches a flat view literal — ViewSchema is a CONTAINER (list / form / listViews / formViews) #15171, which owns that entry. ⛔ Not touched here (editing view's entry is fenced).
  • ⛔ No packages/spec schema was relaxed, considered or touched. The new assertion's failure message says so in its own text: a wrong row is corrected on the ROW.

The floor question, held

This adds assertion coverage inside a test file that already runs in CI — a blind-spot repair of an EXISTING gate. ⛔ It adds no new required gate, hook or ratchet, no workflow step, no check:* script, no package.json script. The distinction ruling F draws (adding a door that must be passed vs. making an existing door see more) was never reached in the wrong direction; nothing in the delivery required it.


Generated by Claude Code

…ces too

The sweep read the `example` face only — the one face `evaluate` reads. The
`required` / `optional` tables name keys as well, and no assertion in this file
had ever read a row's `name` against a schema, so the half of #16925's defect
that lived in the table rows was unguarded: a reader copies a row, the schema
silently strips the key, and the query runs unfiltered under an ordinary
success.

The design work is the row grammar. A row name is read as an ALTERNATION —
split on `|`, trim — and judged iff every part is a bare identifier, which makes
`view`'s four-slot required row judgeable as four keys instead of waved through
as prose. A row that does not fit must be declared in `PROSE_ROWS` (empty
today), and a stale declaration fails too.

Two techniques, because one entry does not read: `.shape` for the eight bound
entries that expose one, and a behavioural probe for `ActionSchema`, which
resolves to a pipe. Each entry's test carries an inline control key that must
read ABSENT, since `action` has no second opinion.

Every row on all nine bound entries names a real key today, so this lands green.

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
…y the open one

A strict schema refuses the row's key by name; an open one takes the object,
strips the key and reports success. The message named only the second, so the
verdict read wrong on the eight strict entries.

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 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.

Coarse fallback — 0 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 1e496f9796126b0398c026c96fb8d240f1982e91 → packageMentionDocs.

@github-actions github-actions Bot added the tests label Sep 16, 2026
@os-support-ai os-support-ai added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 16, 2026 — with Claude
@os-support-ai
os-support-ai marked this pull request as ready for review September 16, 2026 22:10
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 582d3e5 Sep 16, 2026
39 of 40 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-17266-explain-key-retention-table-faces branch September 16, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[gap] os explain's key-retention sweep reads the EXAMPLE face only — the optional/required TABLE rows carry key names and nothing guards them

2 participants