Skip to content

feat(spec,lint): a bulk action's confirmText is a template slot over count and its params, judged at validate, compile and the publish gate - #24947

Draft
objectstack-fleet[bot] wants to merge 18 commits into
mainfrom
claude/issue-24798-bulk-confirm-template-slot
Draft

objectstack-fleet[bot] wants to merge 18 commits into
mainfrom
claude/issue-24798-bulk-confirm-template-slot

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24798

Clause-②: yes (widening)
Metadata-protocol: semantic

The spec half of ruling B on the decision card, step 1: a list-view bulk action's confirmText becomes an ADR-0032 template slot over a closed scope. The scope's roots are count and the action's params by name. Its holes are judged at objectstack validate, objectstack compile and the publish gate by the template judge the flow text slots already use.

The ruling this executes (comment 6285948976, items 1–2, verbatim)

  1. ui/bulk-action.zod.ts:403 confirmText becomes a TemplateExpressionInputSchema slot (ADR-0032 Decisions 2 and 3): a bare string is shorthand for { dialect: 'template', source }; holes are {{ path }} and {{ path | formatter }} over the bulk-confirm scope, whose bound roots the spec declares — count, the affected-record count, and the action's params by name (the PR fixes the root spelling, {{ tier }} or {{ params.tier }}, consistent with the flow scope's roots); an unbound root and a single-brace token are refused by name at objectstack validate, compile and the publish gate through the one judge the flow text slots use (automation/flow-text-slot-template.ts); ⛔ no CEL logic inside a hole (an ADR-0032 non-goal); formatters from the existing whitelist.
  2. The console fills the slot through the template engine the flow notifications render with: step 1 {{count}} now; step 2 the param roots once the spec PR states the display-value rule (option label against stored value for picklists and lookups; number, date and currency via the whitelist). The renderer half is list bulk action confirmText: fill the template slot's declared holes — {{count}} first, the action's params second (the renderer half of objectstack#24798 ruling B) objectui#13183, pm:blocked on this card until the spec release is installable there (ruling D on [Decision] A card whose premise or fix lives in a repository its lane cannot reach (cloud today): who reads it, who acts, and what the lane does meanwhile #24591).

What changed

  • The slot. bulkActionDefs[].confirmText (packages/spec/src/ui/bulk-action.zod.ts) takes the input TemplateExpressionInputSchema takes: a bare string is shorthand for { dialect: 'template', source }, and an envelope must declare dialect: 'template'. Like a notify node's title / message, it is built from the package-internal templateExpressionInput constructor, so its refusal prescribes this slot's own hole, {{ count }}. The shared {{record.name}} would name a root this slot does not bind. A stored sentence with no hole parses as before, its text unchanged.

  • The declared roots. bulkConfirmTemplateRoots(def) and BULK_CONFIRM_COUNT_ROOT (@objectstack/spec/ui) return count, then each param's name in declaration order.

  • Root spelling: bare ({{ tier }}), not {{ params.tier }}. The flow text slots' scope writes its roots bare: a flow variable is {{ v }}, a node output {{ lookup.result }}, and nothing is namespaced. HotCRM's 12 sentences already write {{count}} / {{tier}}, and ruling item 3 has them stand unchanged. count wins over a param named count; such a param stays reachable only through its dialog field.

  • The display-value rule step 2 waits on, stated in the slot's .describe(). A param hole renders the param's DISPLAY value:

    • an option-bearing param renders the chosen option's label, with a multi-value param's labels joined by ', ';
    • a lookup or user param renders the picked record's display name;
    • any other param renders the entered value, so number, date and currency values take a whitelisted formatter ({{ amount | currency:EUR }}, {{ due | date:long }}).

    A hole holds no logic.

  • One judge, not a fork. packages/spec/src/automation/flow-text-slot-template.ts gains a closed-scope entry, closedScopeTemplateRefusal(text, { slot, roots }). It uses the same single-brace token reader, the same hole grammar and the same root rule as textSlotTemplateRefusal; the internals are factored to shared helpers (pathRoot, pathHoles, a predicate on doubled). The flow judge's verdicts and sentences are unchanged, with a control pin. The judge's envelope reader is exported as templateSlotSource, so a door reads a template slot the way the judge does.

  • The doors. validateStackExpressions (packages/lint/src/validate-expressions.ts) is the rule objectstack validate, compile and the publish gate all run. It gains a bulk confirmText pass with the same two steps as the flow text slots, in the same order:

    1. closedScopeTemplateRefusal names a single-brace token (with its {{ }} rewrite when the root is bound) and a hole over an unbound root;
    2. a text carrying neither is compiled with validateExpression('template', …), which names logic or an unknown formatter in a hole.

    A blank sentence is named too. The walk covers a view's list and listViews, a standalone view's flattened list overlay and its ViewItem record (the shapes PUT /meta/view stores), and an object's own listViews. The publish gate's expression rule now declares view in runtimeTypes. A view write runs this pass alone, by construction, and an object write also judges its own listViews.

The 「甲」 rule: what passes today, and why every finding is a warning

Measured on origin/main b42c621c05: confirmText was z.string().optional(). Every string passed, including an unbound {{ x }}, a single-brace {x}, a hole holding logic ({{ count * 2 }}) and a blank ''. Refusing any of them would be a narrowing that no ADR-0087 conversion carries. So, in 18.x:

The widening is the envelope form and the declared holes: Clause-②: yes (widening), minor for @objectstack/spec and @objectstack/lint.

Landing order: steps 1 and 2 have landed; this PR is step 3

⚠️ Retyping changes the served shape: a parsed confirmText is now { dialect: 'template', source }, not a string. objectui used to render it as a React child (plugin-grid/src/components/BulkActionDialog.tsx), so the renderer had to move first. It has:

  1. fix(plugin-grid,types): read bulk confirmText as a string or a template envelope objectui#13319, the envelope reader, landed as objectui cd14d8eeffe1. BulkActionDef.confirmText is string | { dialect?, source }, and the dialog renders through bulkConfirmTextSource.
  2. chore(console): move the objectui pin 56cf4d8e89b7 → cd14d8eeffe1, past objectui#13319's bulk confirmText envelope reader, so #24947 can land #25193 moved .objectui-sha to cd14d8eeffe1 (main 9057fd41f5).
  3. This PR carries both, through merge 823f523b15.

The fill step ({{count}}, then the param roots) stays on objectstack-ai/objectui#13183, as ruling 6285948976 describes.

Why Fixes, not Refs

The ruling splits the work by repository (ruling D on #24591). This card carries the spec half, and the renderer half is its own card, objectstack-ai/objectui#13183, whose body reads Blocked-by: objectstack-ai/objectstack#24798. If this card stayed open until the renderer landed, the renderer card would wait on a card that waits on it. So merging the spec half closes this card. The sibling sweep, #24814, is not addressed here; it stays open and follows serially. The landing-order hazard above is a sequencing condition on merging this PR, not a reason to keep the card open.

Contract review

This is a published @objectstack/spec schema, so it needs contract review at CONTRACT_REVIEW_TIER. The diff touches no governed surface (.claude/**, docs/adr/**, AGENTS.md, skills/** and .github/workflows/** are all untouched), so it is neither Tier S nor Tier H. It is under 800 changed lines, under the 3000-line human-merge threshold.

Tests (final head 823f523b15, after merging origin/main twice: ef2b46b524, then 2919ddb875, which carries #25193)

  • spec: 708 files / 21498 passed, 1 todo. test:repo 74 / 1269 passed. typecheck and check:generated exit 0.
  • lint: 154 files / 7091 passed. typecheck exit 0, with check:test-typecheck OK.
  • cli i18n unit tests (3 files) 50 passed, typecheck exit 0. dogfood expression-conformance 8 passed.
  • Changeset gates green. check-changeset-no-major --event against the live body reads Clause-②: yes (widening) and Metadata-protocol: added, and grades nothing patch.
  • dispatch-gates at 823f523b15: 115 derived, 114 run at exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, which needs a full build; CI owns it).

Earlier rounds (head 9fb508c8a0):

  • pnpm --filter @objectstack/spec test → Test Files 704 passed (704), Tests 21379 passed | 1 todo.
  • pnpm --filter @objectstack/lint test → Test Files 152 passed (152), Tests 7009 passed.
  • pnpm --filter @objectstack/dogfood exec vitest run test/expression-conformance.test.ts → 8 passed. The new slot is discovered and classified (template-bulk-confirm-text, experimental / unevaluated: nothing in this checkout fills the holes yet).
  • pnpm --filter @objectstack/cli exec vitest run --project unit on the seven i18n suites that read confirmText (i18n-bulk-action-coverage, i18n-coverage, i18n-extract, i18n-walk-output-parity, i18n-declared-surface-gate, i18n-duplicate-demand, i18n-extract-action-description) → 100 passed. pnpm --filter @objectstack/cli typecheck → OK. The cli integration layer is declared to CI.
  • Ablation of the extractor fix (node scripts/ablation-replace.mjs on packages/cli/src/utils/i18n-extract.ts, templateSlotSource(def.confirmText) → def.confirmText): the blob went 4c0e88d86ced → dc3e90d6ba5f, and the new envelope pin went red (Tests 1 failed | 17 passed). The restore leg brought the blob back to HEAD and git diff HEAD was empty.
  • pnpm --filter @objectstack/spec --filter @objectstack/lint typecheck → both Done.
  • pnpm --filter @objectstack/spec check:generated → stale api-surface/, export-origins/ and the reference docs, regenerated with --fix (exactly the five new exports and the confirmText rows).
  • The derived gate battery (node scripts/pm/dispatch-gates.mjs --commands, 115 families at 9fb508c8a0), reconciled with --ran: 114 run at exit 0, and 1 NOT MEASURED: check:dual-build-cjs-loads exited 3 (prerequisite not met: it loads the BUILT entry of every package, about 90), which is declared to CI. check:i18n, check:i18n-coverage, check:i18n-walk-parity and check:skill-examples went green after building the closures they name, and check:type-check-debt went green on a run without a timeout wrapper.
  • eslint, narrowed and proven:
    • population: eslint.config.mjs files: ['**/*.{ts,…}'] minus NEVER_LINTED, with --print-config confirming each touched file is linted;
    • npx eslint --no-inline-config --format json over the 15 touched TS files → 15 files, 0 errors, 0 warnings;
    • invariance: the config enables no type-aware linting (no parserOptions.project, per its own comment at eslint.config.mjs:325), so this diff cannot move a verdict on an untouched file.
  • Ablation of the publish-gate widening, done after committing (node scripts/ablation-replace.mjs on packages/lint/src/authoring-rules.ts, removing 'view' from runtimeTypes): the anchor went 1 → 0 and the blob 37ae7e87aacf → 82d615ca2df0. All 3 gate positives in validate-expressions.bulk-confirm-text.test.ts went red (Tests 3 failed | 11 passed), and the 3 controls stayed green. The restore leg brought the blob back to HEAD and git diff HEAD was empty. No dist was involved, since lint's own tests read src.

Acceptance notes

  • os i18n extract and an envelope-form confirmText (the seat extended the claim to packages/cli/src/utils/i18n-extract.ts). pushBulkActionDefs now reads the slot through templateSlotSource(def.confirmText), so a confirmText written as { dialect: 'template', source } is seeded (sourceValue / inline) exactly as the bare string. Before this, authoredEvidence read a string or an inline locale map only, and the envelope was recorded like an absent key. The new pin is in packages/cli/test/i18n-bulk-action-coverage.test.ts.
  • Translation bundles. A translated bulk confirmText in a bundle (objects.OBJ._views.VIEW.bulkActions.DEF.confirmText) is still a plain string, and nothing here judges its holes. Ruling item 1 binds the def's slot, and the bundle side is the sibling sweep's question.
  • A param named count is shadowed by the count root (documented in the slot and in bulkConfirmTemplateRoots). No such param exists in the repository corpus.
  • Corpus measured at b42c621c05: 6 bulk defs carry a confirmText (examples/app-showcase project.view.ts), none with a brace, so 0 findings at every door. HotCRM's 12 sentences ({{count}}, {{tier}}) are pinned clean in both spec and lint tests, the zh-CN form included.

Files outside the claim's listed paths, each forced by the slot change and named here:

  • packages/spec/src/shared/typed-expression-input.ts: one docblock line naming the constructor's new caller;
  • packages/spec/src/ui/component-props-unknown-members.pin.test.ts: the envelope's ast ledgered under EXPRESSION_AST, beside bulkActionDefs[].visible.ast;
  • packages/spec/src/ui/action-confirm-params-guard.test.ts: the parsed value is now the envelope;
  • packages/qa/dogfood/test/expression-conformance.ledger.ts: the slot's conformance row;
  • packages/lint/src/{authoring-rule-wiring,runtime-gate,runtime-gate.page-visibility-predicate-writes,validate-expressions}.test.ts: the view rule lists, and the receiver table of the declared-keys meta-test;
  • packages/cli/src/utils/i18n-extract.ts and packages/cli/test/i18n-bulk-action-coverage.test.ts: the envelope-form seed and its pin (claim extended by the seat); .changeset/24798-bulk-confirm-template-slot.md now also bumps @objectstack/cli.

Generated by Claude Code

claude added 10 commits October 20, 2026 15:46
… closed-scope judge

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
…rmText's holes, warnings in 18.x

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
… view-write gate

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
…s for the bulk confirmText slot

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
…nd the unknown-members ledger

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
…ression-conformance ledger

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

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/lint, @objectstack/spec, touching 33 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/api-surface/ui.json, packages/spec/export-origins/automation.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2919ddb875cd0daa38ef452d741bcba04cf366af.

⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/api-surface/ui.json, packages/spec/export-origins/automation.json, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 150 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 2919ddb875cd0daa38ef452d741bcba04cf366af → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8e346007298d5c9d474bbb13c64fd762eb40eb79 — the merge of head 823f523b15b5f621cf38905c6cbc78612a3b8c14 into base 2919ddb875cd0daa38ef452d741bcba04cf366af, 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 8e346007298d5c9d474bbb13c64fd762eb40eb79 && git checkout 8e346007298d5c9d474bbb13c64fd762eb40eb79
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2919ddb875cd0daa38ef452d741bcba04cf366af 823f523b15b5f621cf38905c6cbc78612a3b8c14 && git checkout -B drift-repro 2919ddb875cd0daa38ef452d741bcba04cf366af && git merge --no-ff 823f523b15b5f621cf38905c6cbc78612a3b8c14

node scripts/docs-audit/affected-docs.mjs --json 2919ddb875cd0daa38ef452d741bcba04cf366af

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2919ddb875cd0daa38ef452d741bcba04cf366af → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Re-timed to 18.x (a triage note for this draft). Triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-21T01:38Z

The maintainer amended 「甲」 (#6015 6303865025): 「暂时不考虑 v19,协议的调整原则上也是尽快,不考虑退役版本」, then confirmed triage's re-timing list in the triage seat's chat (「18.x 收回清单 同意」).


Generated by Claude Code

This was referenced Oct 21, 2026

This branch has not been deployed

No deployments
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 protocol:ui size/l tests tooling

Projects

None yet

2 participants