Skip to content

fix(sdui-parser): containment reads the declared children input, not isContainer; lockstep pins the predicate - #20065

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-19969-sdui-containment-slot
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-19969-sdui-containment-slot

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19969

Clause-②: no

What this does

The save gate's validateTree in packages/sdui-parser now decides not-a-container by the component's declared children input. It no longer reads isContainer, and there is no fallback to the flag. This is the byte-equal port of objectui 5ea623ea (objectui#9910 Q1-A, re-cut as objectui PR #10270), which is already inside the console pin f8a9d0fb0596 (PR #20036). The objectui side it mirrors is packages/sdui-parser/src/validate.ts at the pin: CHILD_LIST_KEY at :33, acceptsChildren at :57-59 and the containment branch at :220.

  • Predicate semantics mirrored exactly. The test is comp.inputs.some((input) => input.name === CHILD_LIST_KEY). It matches on the name children alone, whatever the input's coarse type. The dispatch brief said "name children, type slot". objectui deliberately matches on the name only, because the five page kinds publish children typed array, and objectui pins that case. This port follows objectui, not the brief.
  • A component the manifest has no entry for draws unknown-component and gets no containment verdict. This is unchanged and is the same on both sides.
  • isContainer stays on ManifestComponent, re-described as layout containment in the same words objectui uses. index.ts is unchanged: no new public export. objectui exported acceptsChildren and CHILD_LIST_KEY from its entry for its own census tests, and nothing here needs that.

The lockstep pin (maintainer-named in the ruling)

scripts/check-sdui-lockstep.mjs now also compares the containment predicate. The gate extracts the condition of the one if in validate.ts whose body stamps not-a-container. It adds the module-level declarations that condition reaches, transitively (acceptsChildren, then CHILD_LIST_KEY), and reduces the result to its token sequence. Comments and wrapping do not change the reading. A changed token does. The gate compares this reading against a new containmentPredicate field in the vendored objectui record.

  • The record was regenerated with pnpm gen:sdui-lockstep against an objectui checkout parked on the pin f8a9d0fb0596. Every other field of the record is byte-unchanged; the only diff is the new containmentPredicate block.
  • If validate.ts has zero or two containment branches, the gate refuses ([containment-unreachable]). It never measures only one of two.
  • A new self-test battery with 10 cases is added, and the battery floor goes from 6 to 7. The first case asserts that the flag predicate and the slot predicate read differently.

The five served components (tracked sdui.manifest.json at 0bf85eaa, 5 of 59 disagree)

component isContainer declares children before (flag) after (declared input)
badge false yes (slot) warns not-a-container (false) no warning
alert false yes (slot) warns (false) no warning
button false yes (slot) warns (false) no warning
page:tabs true no silent warns not-a-container
page:accordion true no silent warns not-a-container

The new containment-declared-slot.test.ts uses these five entries, copied verbatim from the manifest. Each gets its own case, plus a control asserting that the two predicates really disagree on every one. The file also carries objectui's 2×2 (flag × slot) reading.

Ablation (a trap restores the file, and the restore is proven byte-identical to HEAD by blob hash and an empty git diff HEAD)

  • Leg 1: revert the port. The branch was changed back to !comp.isContainer through scripts/ablation-replace.mjs: anchor 1 to 0, blob 819b7b6a83ac to 670ce98bdb15. node scripts/check-sdui-lockstep.mjs exited 1 with [containment-drift], printing both texts. The file was then restored to blob 819b7b6a83ac = HEAD.
  • Leg 2: sneak the fallback into the helper. The mutation appended an isContainer fallback to the acceptsChildren return. The gate exited 1 with [containment-drift]. Restored to 819b7b6a83ac.
  • Test-suite leg (same mutation as leg 1). The new test file went 8 failed / 5 passed (exit 1). Restored.
  • Restore leg. pnpm check:sdui-lockstep on HEAD f3ff1e6e9 exits 0: the self-test passes, and the gate reports agreement on all 26 codes and on the not-a-container predicate (blob 76c18fb95d1f).

Verification (HEAD f3ff1e6e9; re-run on bfb082b0b in the section below)

  • pnpm --filter @objectstack/sdui-parser exec vitest run: 11 files, 189 tests passed (VERDICT command-exit 0).
  • pnpm --filter @objectstack/sdui-parser typecheck: exit 0. --listFiles includes the new test.
  • pnpm check:sdui-lockstep: exit 0.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 81 commands. --ran reconciles: 77 exit 0, 4 NOT MEASURED, 0 unrun.
    • NOT MEASURED, prerequisite unmet: check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt each exited 3 because they need a full workspace build.
    • NOT MEASURED, over the time cap: check:pm-dispatch-gates. Its self-test ran past the foreground cap, was killed twice, and is declared to CI.
  • check-plugin-teardown-shape --self-test first exited 3 because this is a shallow checkout. It exited 0 after fetching its pinned fixture commit.
  • eslint --no-inline-config over the 6 touched source files: 0 errors, 0 warnings. This is a targeted run, not a proven narrowing, so the full pnpm lint is declared to CI.

packages/lint fixture: resolved by PM ruling 5825043664

The PM ruling on #19969 (comment 5825043664, Q2 = A) widened this card file surface by exactly one test fixture. Commit bfb082b0b gives the hand-made flex entry at packages/lint/src/validate-jsx-pages.test.ts:39 the children input, the same triage objectui made on its own fixtures:

flex: { type: flex, namespace: ui, isContainer: true, inputs: [{ name: children, type: slot }] },

No other packages/lint file changes; authoring-rules.ts and the lint caller are untouched. Q1 = A, provisionally: the changeset stays patch with Clause-②: no, and the contract review judges it on the final head.

On head bfb082b0b, after rebuilding the @objectstack/lint dependency closure:

  • pnpm --filter @objectstack/lint exec vitest run: 108 of 108 files passed; 4133 tests passed, 5 skipped, 0 failed (VERDICT command-exit 0). Before this commit it was 1 failed / 4132 passed.
  • pnpm --filter @objectstack/lint typecheck: exit 0.
  • sdui-parser suite: 189 of 189 passed. pnpm --filter @objectstack/sdui-parser typecheck: exit 0. pnpm check:sdui-lockstep: exit 0.
  • dispatch-gates --commands on the 9-path set derived 82 commands (one new: check:docs-transcript-drift, exit 0 after building @objectstack/lint). --ran reconciles: 78 exit 0, 4 NOT MEASURED, 0 unrun. The four are check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt (each exit 3, prerequisite: a full workspace build) and check:pm-dispatch-gates (over the foreground cap). All four are declared to CI.

Acceptance notes

  • codegen.ts's generateBlockList still labels its column "container" from isContainer. Under Q2-A that column now means layout container. It is unchanged here, and objectui leaves it as-is too.

Generated by Claude Code

…isContainer

Port of objectui 5ea623ea (objectui#9910 Q1-A) to the save-gate copy.

Claude-Session: https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv
Co-authored-by: Claude <noreply@anthropic.com>
…2 and the five served entries

Claude-Session: https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv
Co-authored-by: Claude <noreply@anthropic.com>
…cate against objectui's record

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

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/sdui-parser/objectui-lockstep.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/sdui-parser/objectui-lockstep.json) — pages documenting those are invisible to this run
  • 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 — 2 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 66960564d9b628c446be3c8f8a8f0217c367a17f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 66960564d9b628c446be3c8f8a8f0217c367a17f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

… input

Containment is the declared children input since the sdui-parser port;
the fixture nests a child under flex, so it declares it.

Claude-Session: https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bfb082b0b817f78917639c7ebffb2c2b3feb746b

① Derived judgments

  1. TRUE. The port matches objectui. packages/sdui-parser/src/validate.ts:33 (CHILD_LIST_KEY), :57-59 (acceptsChildren, a name-only match) and :216 (the branch) are token-identical to objectui validate.ts at f8a9d0fb0596, lines 33, 57-59 and 220.
    • The PR's own extractor, run on both files, reduces each to blob 76c18fb95d1f.
    • No isContainer read, no fallback.
    • An unknown component draws unknown-component and never reaches the containment branch, on both sides.
    • 5ea623ea is an ancestor of the pin.
  2. TRUE. The lockstep pin is real. scripts/check-sdui-lockstep.mjs:346-433 extracts the single not-a-container branch plus the module-level declarations it reaches; it refuses on 0 or 2 branches. judge emits containment-drift or containment-unreachable.
    • Run in a scratch worktree: the gate exits 0 and --self-test exits 0 (a 10-case battery, floor 7).
    • Ablation: :216 reverted to !comp.isContainer gives exit 1 with [containment-drift]. Reverted afterwards.
  3. TRUE. Five verdicts flip, both ways. In the head manifest (59 components):
    • badge, alert, button declare a children slot with the flag off, so they are no longer warned;
    • page:tabs, page:accordion have the flag on and no children input, so they are now warned;
    • no other component disagrees.
  4. TRUE. No new public export. index.ts is untouched and still exports only validateTree from validate.ts. The types.ts changes are two JSDoc blocks.
  5. TRUE. The lint change is test data. Only packages/lint/src/validate-jsx-pages.test.ts changes (+4/-1): the flex fixture gains the children slot.
  6. TRUE, with one gap. The changeset prose is accurate: warning severity, no isContainer read, five components changed, page:tabs and page:accordion render item.children only (objectui containers.tsx:891, :1079). The gap: its last sentence names only os validate --strict. validateJsxPages also runs under os lint, and os lint --strict promotes warnings too (packages/cli/src/commands/lint.ts:991-995). os build / compile has no warning promotion.

② Semver level

not-a-container is severity: 'warning' (validate.ts:218, unchanged). os validate and os lint fail on a warning only under --strict (validate.ts:716-740, lint.ts:983-995). So the default save gate's accept and refuse sets do not move. Only the opt-in --strict face moves, in both directions.

Judgment: patch + Clause-②: no is correct. The repo's Clause-② criterion is directional: relaxing the accept set or enlarging the public surface (execution-duties.md:65, scripts/pm/check-widening-tells.mjs:23-27). This diff:

  • adds no key, no enum member, no reachable export and no registration;
  • removes nothing an author can write (children stays a base prop).

It pulls the save gate back to the renderer's declared contract. The --strict movement changes when an advisory fires, correcting a false-positive / false-negative pair. The newly refused shape was already inert at render. A (narrowing) arm is reserved for removing or renaming authorable surface under ADR-0087, and nothing here fits it. check-widening-tells --declaration no exits 0.

③ Boundary flags

  • Governed surfaces: none of the 9 paths. The .changeset prose is reviewed above.
  • Lint fixture: the minimal test-data update the predicate move forces. No packages/lint production source changes, and no baseline row is affected.
  • Check-runs on the head: 41 — 36 success, 5 skipped, 0 failed.
  • Out of scope, card-worthy, not blocking: objectui's index.ts also exports acceptsChildren / CHILD_LIST_KEY, while this copy deliberately does not. objectui's validate.ts:150 also feeds acceptsChildren into a body-dialect check this tree does not carry.

Implemented-by: claude/issue-19969-sdui-containment-slot
Reviewed-by: session_01VDtqoecgES7ScQYGbFVDRv

VERDICT: PASS

Landing condition: amend the changeset's final paragraph so it names os lint --strict beside os validate --strict (os build does not promote). Keep patch and Clause-②: no. The re-issue on the new head covers exactly that one-sentence delta.

Isolated at-tier reviewer, adopted by the domain:devx#1 seat.


Generated by Claude Code

…or the warning's reach

Claude-Session: https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1fb16a27f61f562b54658045c87590574ecc4b12

① Derived judgments

  1. TRUE. git diff bfb082b0b 1fb16a27f touches exactly one file, .changeset/19969-sdui-containment-declared-children.md (+1/-1), in one commit. The replaced sentence now names both os validate --strict and os lint --strict, and says both run validateJsxPages. Verified accurate:

    • os validate fails on a warning only under flags.strict (packages/cli/src/commands/validate.ts:716-740).
    • os lint does the same only under --strict (lint.ts:991-995).
    • validateJsxPages runs in both (packages/lint/src/authoring-rules.ts:1032-1041).
    • compile.ts carries no warning promotion.

    patch and Clause-②: no are unchanged. Judgments 1-6 of the record on bfb082b0b carry over, because no other byte moved.

② Semver level

Unchanged from the record on bfb082b0b. not-a-container stays severity: 'warning', and the default gate's accept and refuse sets do not move. The opt-in --strict movement is a corrected advisory that pulls the save gate back to the renderer's declared contract. patch + Clause-②: no is correct.

③ Boundary flags

  • Governed surfaces: none; the delta is a .changeset file only.
  • Check-runs on 1fb16a27f: 34 — 31 success, 3 skipped, 0 failed, 0 in progress.

Implemented-by: claude/issue-19969-sdui-containment-slot
Reviewed-by: session_01VDtqoecgES7ScQYGbFVDRv

VERDICT: PASS

No landing conditions remain: this delta satisfies the one condition from the prior record.

Isolated at-tier reviewer, adopted by the domain:devx#1 seat.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 02:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 338feda Sep 25, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19969-sdui-containment-slot branch September 25, 2026 03:13
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/l tests tooling

Projects

None yet

1 participant