spec(ui): declare titleField on KanbanConfigSchema — the optional key its five item-titled siblings already carry - #18561
Conversation
`KanbanConfigSchema` was the one item-titled view config of its family that omitted `titleField`, while Gallery, Timeline, Calendar, Gantt and ListMap all declare the key under the same name and the same `z.string()`. The schema is a `strictObject`, so an author writing the key the board actually reads was refused by name. Declared optional, matching the shape `CalendarConfigSchema` already writes down for this exact key: absence resolves through the ADR-0079 record display-name chain, so requiring it would demand more than the renderer reads. Timeline and Gantt spell it required and are the two siblings this declaration deliberately does not copy. Tests carry the four-leg probe with both controls firing on the same call shape, plus the optionality leg. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…tion `authorable-surface/ui.json` gains `ui/KanbanConfig:titleField`; the three reference pages that inline the kanban config shape pick up `titleField?: string`. Regenerated, not hand-edited: `check:authorable-surface` wrote the first and `gen:docs` the rest. Adds the minor changeset for the widening. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 67d8299911aca37ad3db044fbe890779d35791c7 && git checkout 67d8299911aca37ad3db044fbe890779d35791c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 79a046f8cdf085d95200826ee9bb2fa6584bc3d5 6d01b4b63e07b5fbdc0069ce8f5143167b9813ff && git checkout -B drift-repro 79a046f8cdf085d95200826ee9bb2fa6584bc3d5 && git merge --no-ff 6d01b4b63e07b5fbdc0069ce8f5143167b9813ff
node scripts/docs-audit/affected-docs.mjs --json 79a046f8cdf085d95200826ee9bb2fa6584bc3d5 |
Contract reviewServed-tier: 30/30 Isolated, at-tier clause-② review of PR #18561 for card #16894, executing the director ruling (decision batch #87, objectstack-ai/objectui#8367 comment 5582071618, maintainer 「批 #87 同意」). The dispatch order and the dispatching seat's acceptance were withheld from this seat; every reading below was taken from the tree and the diff, on a detached worktree of the head, with the objectui pin ① Derived judgmentsEach accept-set or public-surface change the diff produces, named and judged:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Clause-② adopted — PASS; both carriers stripped in this actReview of record: PR-thread comment 5710778226. Adopted verbatim. A dispatching seat may adopt an isolated reviewer's record whole or void it whole; it may ⛔ never rewrite or polish one, and this seat did neither. Everything below is disposition of the flags the record raises — ⛔ not a supplement to its judgments.
Independence pair
Why an at-tier review exists here at allThis seat measures below Tier evidence, in the shape that rule demands: the reviewer parsed every The reviewer was fed the card, the ruling it executes, and the PR body/diff — ⛔ not the dispatch order and ⛔ not this seat's own acceptance conclusions. Disposition of the reviewer's boundary flags
Landing pre-checks, read on this head
Next: ready → auto-merge, then followed to MERGED.
Generated by Claude Code |
…— PENDING_GOVERNANCE reaches empty (objectstack-ai#18609) Fixes objectstack-ai#18582 Clause-②: no `PENDING_GOVERNANCE` reaches **empty**. `connector` and `analytics_cube` — the two debts left on this card after `sharing_rule` was paid by PR objectstack-ai#18587 — move into `GOVERNED` with a ledger each, so every authorable metadata type in the denominator objectstack-ai#18133 widened now has one. ## What was measured **`connector` — 74 properties: 20 `live`, 1 `planned`, 53 `dead`.** - **Which schema the walker really resolves** (the seat's open question 1): `getMetadataTypeSchema('connector')` returns `DeclarativeConnectorEntrySchema`, and that schema is `ConnectorSchema.superRefine(...)`. In Zod 4 a `superRefine` attaches a check to the **same object def** rather than wrapping it, so `shapeOf()` returns `ConnectorSchema`'s shape unchanged: the walked key set is byte-identical to the base's, tombstones included. **The gate cannot tell the two schemas apart.** What the entry schema buys is refusals, which are invisible to the walk and show up only on the three rows where they are the whole verdict. That difference is recorded in the ledger's `_note` and in the README row. - **One schema, two doors** is the shape fact behind the split. The ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a plugin or an ADR-0097 provider factory builds in code. So a key can have a real consumer and still do nothing when a metadata author writes it — every row says which door its consumer is fed from, and every `live` row carries a `producer` (objectstack-ai#4837). - The keys an authored entry can reach are exactly the `ConnectorProviderContext` fields plus `name` and `enabled`. `type` and `icon` reach that context and are dropped by **all three** shipped provider factories (`ctx.icon` census: zero reads across `packages/connectors`, with `ctx.label` — four hits — as the lit control). `authentication` is the ledger's one `planned`: refused outright by ADR-0097 §3 (objectstack-ai#7990), never ignored. - The 53 `dead` are four declared subsystems with no engine (`syncConfig` 7, `fieldMappings` 7, `retryConfig` 8, `health` 14), `triggers` (6 — the schema's own docblock already said so, objectstack-ai#3197), the connector's nested `webhooks`, `status`, `metadata`, both timeouts, `actions.description` / `.outputSchema`, and four `retiredKey` tombstones whose rows stay because the key stays in the walked shape (the `rls.priority` precedent). **`analytics_cube` — 29 properties: 17 `live`, 12 `dead`.** - **An honest `dead` was the outcome on 12 rows** (the seat's open question 2), and none was inflated to green the gate. `cube-registry.ts` names three producers into one registry — authored cubes, compiled datasets (ADR-0021) and ad-hoc query inference — and only the first is the door this ledger governs, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format`. - **Whether ADR-0049 wants a retirement is answered per row, and mostly the answer is no** — the ledger says so explicitly so the enforce-or-remove channel does not act on the word `dead`. `granularities` and `measures.format` are the dataset compiler's own output channel on a shared shape: deleting them breaks a live internal write. `joins[].sql` is REQUIRED and documented as the ON clause while the strategies synthesise an FK equality and never consult it — a decision, not a sweep. `public` is an access-control flag that gates nothing (three sites write `false`, nothing reads it): a knob that was never wired, not a hole that was opened. `refreshKey.every` / `.sql` are the only rows where retirement is the obvious shape, and even there the showcase example authors them. - **objectstack-ai#10238 is not prejudged.** Whether cube authoring is live end to end remains its own measurement; this ledger answers the per-key question only, and says so in the `_note`. **Two prior in-repo claims were falsified by this measurement and are corrected in the ledgers** (not in their source files — that is out of scope here, and both are filed below): 1. `packages/spec/src/conversions/registry.ts` states `retryConfig` "and the timeouts beside it are untouched — they are live". The word `retryConfig` does not occur anywhere in `packages/` or `examples/` outside `packages/spec`, and every `connectionTimeoutMs` / `requestTimeoutMs` occurrence is a WRITE of the literal 30000 so a def satisfies the post-parse type. 2. `bootstrapDeclaredWebhooks` documents itself as materializing each "stack/connector-authored webhook", while its source is `readDeclared(…, 'webhook')` — metadata items the decomposition registers from the top-level `webhooks:` collection, which a connector's nested array never becomes. ## The gate, red before and green after Both ledgers in place and both types in `GOVERNED`, before the README / counts caught up — `pnpm --filter @objectstack/spec check:liveness`, **exit 1**: ``` ✗ 2 governed type(s) with NO row in the README state table: connector analytics_cube ✗ 1 README state-table heading error(s): heading says 37 governed types, GOVERNED has 39 ✗ the generated count artifact is not current: packages/spec/liveness/state-counts.md is STALE — it does not match what the gate measures right now. first difference at line 67: - | **total** | **878** | **5** | **1** | **96** | **11** | **991** | + | `connector` | 20 | 0 | 0 | 53 | 1 | 74 | ✗ 2 row(s) where README.md and state-counts.md disagree: connector — counted in state-counts.md, no row in the README table analytics_cube — counted in state-counts.md, no row in the README table ✗ 1 UNDECLARED container inheritance — a blanket verdict covers keys nothing classified: connector/webhooks — one verdict covers 21 unclassified child key(s): … ``` That run is also the answer to the seat's warning about `liveness/README.md`: `check-liveness.mts` declares `readmeMissingRows` for exactly this, so the README rows, the heading count and `state-counts.md` are not optional extras — the gate reverse-requires them. After the README rows + heading (37 → 39), `gen:liveness-counts`, and the `connector/webhooks` row in `undrilled-containers.baseline.json` — **exit 0**: ``` governance denominator: 30 authorable type(s) — 26 registered kind(s) + 4 unregistered-kind stack collection(s) (analytics_cube, connector, sharing_rule, webhook); 30 governed, 0 awaiting a ledger. (+ 9 type(s) governed from OUTSIDE the denominator via SPEC_ONLY_SCHEMAS — 39 governed in total.) ✓ every governed-type property, at every depth the ledger drills, is classified, every authorable type — registered kind or unregistered-kind stack collection — is governed or explicitly pending, … and the README state table carries a row for each of the 39 governed type(s) it claims to index. ✓ packages/spec/liveness/state-counts.md is current — the same 39 row(s). ``` `connector/webhooks` is RECORDED in the undrilled baseline rather than deferred or drilled, and the ledger row says why: `WebhookConfigSchema` is `WebhookSchema.extend({ events, signatureAlgorithm })`, so a `deferred` row to the governed `webhook` type would be refused by the gate's key-set EQUALITY check — correctly — and drilling would mean writing 21 child rows of which 8 are the ADR-0010 protection envelope this gate auto-classifies `live` everywhere else. ## Verification | Command | Result | |---|---| | `pnpm --filter @objectstack/spec check:liveness` | exit 0 — `PENDING_GOVERNANCE` empty, 39 governed | | `pnpm --filter @objectstack/spec check:generated` | exit 0 — all 15 generated artifacts up to date | | `pnpm --filter @objectstack/spec check:authorable-surface` | exit 0 — 1536 schemas generated | | `pnpm --filter @objectstack/spec check:api-surface` | exit 0 — public API surface unchanged | | `pnpm --filter @objectstack/spec check:docs` | exit 0 — 223 generated files in sync | | `pnpm --filter @objectstack/spec typecheck` + `check:scripts-typecheck` | exit 0 | | `pnpm --filter @objectstack/spec exec vitest run scripts/liveness/` | 11 files, 315 tests passed | | `pnpm check:platform-checklist` | exit 0 — 38 kinds mapped, 1 waived | | `pnpm check:nul-bytes`, `check:published-files`, `check:merge-driver`, `check:doc-authoring`, the three changeset gates + their self-tests, `check:keyed-text-bounds`, `check:comment-mask-*`, `check:closing-keyword-parity`, `check:pm-*` | exit 0 (22 families) | `packages/spec` has no `lint` script; the repo runs one root `eslint . --no-inline-config`, so the ESLint reading here is a **declared narrowing** with its three pieces of evidence: (1) the universe comes from the config itself — the only config object with a `files` glob for source is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, and the seven non-`.mts` paths in this diff are `.json` / `.md`, confirmed by running ESLint on `liveness/connector.json` and getting `File ignored because no matching configuration was supplied`; (2) `--format json` on the one file this diff adds to that universe reports **1 file, 0 errors, 0 warnings**; (3) `eslint.config.mjs`'s own header states this repo "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file", so nothing in this diff can move the verdict on a file it does not touch. The whole-farm run is CI's. Both figures are read at `a442b583fb`. **Widening tells.** `node scripts/pm/check-widening-tells.mjs --declaration no --diff DIFFPATH (this PR's diff)` exits **0 with no tell**, matching PR objectstack-ai#18587 — but its own output is the honest reading and it is not "clean": `8 changed file(s) — 0 judged against a declared surface (no widening tell), 8 NOT MEASURED`, because no declared surface covers ledger JSON, a changeset, a checklist map or a gate script. Reported as NOT MEASURED rather than as a pass. ## File surface | Path | Why | |---|---| | `packages/spec/liveness/connector.json` | new ledger (23 top-level rows, 7 drilled containers) | | `packages/spec/liveness/analytics_cube.json` | new ledger (9 top-level rows, 4 drilled containers) | | `packages/spec/scripts/liveness/check-liveness.mts` | both types into `GOVERNED`; `PENDING_GOVERNANCE` emptied; its `[objectstack-ai#18582]` note rewritten (it said "two left") | | `packages/spec/liveness/README.md` | from the pre-declared OPEN set — two state-table rows and the heading count 37 → 39, both reverse-required by `readmeMissingRows` / `readmeHeadingErrors`; the closing `PENDING_GOVERNANCE` paragraph rewritten | | `packages/spec/liveness/state-counts.md` | OPEN set — regenerated with `gen:liveness-counts`, never hand-edited | | `packages/spec/scripts/liveness/undrilled-containers.baseline.json` | one recorded row, `connector/webhooks`, reverse-required by the container-coverage leg (see above) | | `docs/qa/platform-checklist/coverage.json` | OPEN set — two entries; the map is keyed by ledger name and `check:platform-checklist` reds on an unmapped kind. Existing key order left as it was | | `.changeset/18582-connector-analytics-cube-liveness-ledgers.md` | OPEN set — `patch`, because `liveness` is in this package's published `files[]`, so both ledgers ship in the tarball | Neither `packages/spec/src/ui/view.zod.ts` (PR objectstack-ai#18561) nor `packages/spec/scripts/check-generated.ts` (objectstack-ai#17735) is touched. ## Acceptance notes Seen and deliberately not fixed here — three are findings this seat asks the dispatching seat to file, the rest are noted only: - **to file (contract violation; dedupe words: `retryConfig` live claim, connector timeouts, conversions registry comment):** `packages/spec/src/conversions/registry.ts`'s `connector-rate-limit-config-removed` entry asserts `retryConfig` "and the timeouts beside it are untouched — they are live". Measured false; the comment is what a later reader will trust. - **to file (contract violation; dedupe words: `bootstrapDeclaredWebhooks` docblock, connector-authored webhook, sys_webhook source):** the materializer's docblock claims it materializes each "stack/connector-authored webhook"; its source is `readDeclared(…, 'webhook')`, which a connector's nested array never reaches. - **to file (metadata-authoring trap; dedupe words: `analytics_cube` joins sql ON clause, synthesised FK equality, cube join relationship):** `Cube.joins[].sql` is REQUIRED and documented as the join's ON clause, and both strategies synthesise `ON "parent"."seg" = "alias"."id"` without reading it, so a non-FK join condition returns a 200 carrying different arithmetic than the author declared. `joins[].relationship` is the same shape one key over. - **noted, not filed:** `packages/spec/docs/SYNC_ARCHITECTURE.md` still ticks "✅ Monitoring: Health checks, metrics, logging" and "✅ Conflict Resolution: Multiple strategies" at L3, both unbacked on this surface — the `health` and `syncConfig` subtrees are dead. Carrier: the next PR that acts on the `syncConfig` / `health` ADR-0049 decision; that file is the one an author reads before writing either block. - **noted, not filed:** `analytics_cube.public` is an access-control key that gates nothing. Not filed separately because the ledger row IS the record and the remedy is the ADR-0049 decision the `dead` verdict opens. Carrier: the enforce-or-remove sweep that reads this ledger. - **noted, not filed:** `Metric.name` / `Dimension.name` are required inner fields shadowed by their record key, so a disagreement is silently resolved in the key's favour. Carrier: none — no PR and no person is near these files today; recorded here so a later sweep does not have to re-derive it. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #16894
Clause-②: yes (widening)
KanbanConfigSchemanow declarestitleFieldas optionalz.string(), executing the director seat's decision batch #87 (recorded at objectstack-ai/objectui#8367 comment5582071618, confirmed by the maintainer verbatim 「批 #87 同意」). The direction was settled there; this PR is the implementation only.What moved
packages/spec/src/ui/view.zod.tstitleField: z.string().optional()onKanbanConfigSchema, plus the docblock that records why it is optionalpackages/spec/src/ui/view.test.tspackages/spec/authorable-surface/ui.jsonui/KanbanConfig:titleFieldcontent/docs/references/ui/view.mdx,content/docs/references/api/protocol.mdx,content/docs/references/data/object.mdxgen:docs.changeset/16894-kanban-config-titlefield.md@objectstack/spec: minorRegenerated, never hand-edited:
check:authorable-surfacewrote the first andpnpm --filter @objectstack/spec gen:docsthe rest.check:generatedreports all 15 artifacts up to date.Premise re-derivation — the card's sibling table does NOT match the tree
Re-derived on this worktree at
origin/main79a046f8c(the dispatch's own derivation ran one commit behind, at582d3e5).git grep -n 'titleField' -- packages/spec/src/ui/view.zod.tsreturns six carriers; mapping each to the schema whose member list encloses it::1057GalleryConfigSchema(declared:1050):1071TimelineConfigSchema(declared:1065):1407CalendarConfigSchema(declared:1401):1490GanttConfigSchema(declared:1484):1638ListMapConfigSchema(declared:1631)KanbanConfigSchema(declared:1346)Two corrections to the card body, neither of which disturbs the ruling:
ListMapConfigSchemaalso declarestitleField, optional, and the card does not mention it.The ruling is unaffected: it prescribes the arity directly (
optional z.string()) and namesCalendarConfigSchema's docblock as the reference, which is optional at:1407. So the shape landed here is the ruled one, and the card's optional/required split was simply not re-measured when it was written. The defect itself re-derives exactly as filed:KanbanConfigSchemais astrictObjectand refusedtitleFieldby name.Evidence
pnpm --filter @objectstack/spec buildVERDICT command-exit 0pnpm --filter @objectstack/spec typecheckTYPECHECK_EXIT=0—check:test-typecheck: OKpnpm --filter @objectstack/spec testTEST_EXIT=0—Test Files 483 passed (483)/Tests 13775 passed (13775)pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/ui/view.test.tsTest Files 1 passed (1)/Tests 376 passed (376)pnpm --filter @objectstack/spec check:generated① is empty by construction:
packages/spechas no workspace dependencies, so--filter '@objectstack/spec^...' buildhas an empty closure. The package itself was built before any gate that readsdist/.③ Gate families were derived on this worktree, not inherited:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsover the real 7-path change set yields 108 families. All 108 were run with each exit code landed to disk before being read, and reconciled with--ran:103 green. The five non-zero results, none of them a finding against this diff:
pnpm check:dual-build-cjs-loads,pnpm check:lean-entry-closure,pnpm check:type-check-debt— exit 3, PREREQUISITE NOT MET, all three refusing because the whole monorepo is not built on this worktree. NOT MEASURED, declared to CI, which checks out and builds fresh.node scripts/check-plugin-teardown-shape.mjs --self-test— exit 1, but a prerequisite refusal in its own words: "cannot read the positive control at621a487607881c66b2899b7e3477115229a156b4… Deepen the clone". This container's checkout is shallow. NOT MEASURED. The gate itself (without--self-test) ran green.pnpm check:cross-package-test-inputs— exit 1, pre-existing on the base tree, proven by control rather than asserted. With all seven changed paths restored to79a046f8c(and the changeset removed), the gate fails identically; the finding it prints namespackages/cli/test/init-created-files-summary.e2e.test.tsdescendingpackages/spec/dist/, and this diff touches neither that test, norturbo.json, nor any declaration table. Restore was proven bygit diff HEADempty plus agit hash-objectmatch against every HEAD blob.Lint, as a proven narrowing rather than a repo-wide sweep.
pnpm exec eslint --no-inline-config --format jsonover the two changed TypeScript files: exit 0, 2 files, 0 errors, 0 warnings, the file count read from the JSON output's own length. The population it narrows from is 6798 files — computed from ESLint's own resolved config viaESLint#isPathIgnoredovergit ls-files, not estimated. The narrowing excludes nothing, and that is a property of this repository's config rather than a hope:eslint.config.mjsstates it in its own comment — this repo "runs oneeslint.config.mjs, which never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file, test or not." With no rule reading types across a file boundary, a diff confined to these two files cannot move the verdict on any of the other 6796. The repo-widepnpm lintrun remains CI's.Every reading above was taken at
6d01b4b, this branch's final commit, on a tree whosegit status --porcelainis empty.origin/mainhad not moved from the branch point (79a046f8c) when the PR was opened, so no merge was owed.Acceptance notes
packages/lintdoes not field-check the key this PR declares.POSITIONS.kanbaninpackages/lint/src/validate-list-view-field-refs.tslistsgroupByField(error),summarizeField(warning) andcolumns(warning) — and notitleField. Every sibling face lists it:calendar.titleFieldwarns,gantt.titleFieldandtimeline.titleFielderror. So from this release a stale or misspelledkanban.titleFieldnames nothing and is reported by nothing, while the identical typo one block away is caught. Read-only on this card by dispatch, so it is reported and not edited; nothing in this PR turns it red, and no gate reconciles that table against the spec's member lists. Worth its own card.KanbanSchemacarries three zero-read members (allowCollapse,cardTemplates,columnWidths) and the board reads an undeclaredtitleField— enforce-or-remove on the shape objectui#7664 declared objectui#7742) has no home here, and the card's conditional resolves to "no".ObjectKanbanPropsSchemainpackages/spec/src/ui/component.zod.tsalready declarestitleField: z.string().optional(), described as "Legacy fallback forcardTitle(the board readscardTitle || titleField). PrefercardTitle". That is a different semantic on a different schema in a different file — a deprecated alias forcardTitle, not a view-config field binding — so one declaration cannot serve both faces and this card is not widened. That file is also held by spec(ui):ComponentPropsMaphas no rows forobject-map,object-ganttandobject-tree— add them from the renderers' read points (#7751 method) so 「以协议为准」 resolves for all five ladder blocks (objectui#8348 Q1, ruled C) #18305 / PR feat(spec): object-map, object-gantt and object-tree get ComponentPropsMap rows, derived from the renderers' read points (objectui#8348 Q1-C) #18403; it was read, never touched.KanbanConfigSchemacarries no object-level.describe(), whereGalleryConfigSchema,TimelineConfigSchema,CalendarConfigSchemaandListMapConfigSchemaall do, so its nested-shape heading in the generated reference renders with no description sentence. Cosmetic, generated-docs only, no accept set involved — left for whichever card next edits this schema.Refs:line.File surface
Two files beyond the dispatch's declared surface, both generated by the mandated
gen:docsrun and neither hand-edited:content/docs/references/api/protocol.mdxandcontent/docs/references/data/object.mdx. The kanban config shape is inlined on those two reference pages as well as onui/view.mdx, so each picks uptitleField?: stringin itskanbanrow. Omitting them would leavecheck:docsred. Every hunk in all three files is this one key and nothing else.Generated by Claude Code