Skip to content

fix(spec)!: a dataset-bound dashboard widget owns appearance, the dataset owns structure - #19363

Merged
os-zhuang merged 8 commits into
mainfrom
claude/issue-17385-chartconfig-precedence-half-2
Sep 20, 2026
Merged

os-zhuang merged 8 commits into
mainfrom
claude/issue-17385-chartconfig-precedence-half-2

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #17385

Clause-②: yes (narrowing)

⚠️ The claim comment on the card declares Clause-②: no, and this PR's measurement disagrees. The refusals narrow, as the claim and ruling item 5 say — but implementing ruling item 2 forces ONE widening, so the honest declaration carries the yes value with the narrowing arm (the spelling check-adr-0087-registration.mjs pins for "a diff that widens AND narrows"). ⛔ Nothing here touches the card's declaration or the needs:contract-review label; the correction is the PM seat's, and the measurement it rests on is the accept-set table below.

What is ruled, and what this implements

Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options C+D together. DashboardWidgetSchema.dataset is REQUIRED, so every dashboard widget is dataset-bound, and ADR-0021 already made the dataset the owner of the chart's structure. chartConfig nonetheless declared type / xAxis / yAxis / series, with no rule between the two answers.

That was not merely inert. An authored yAxis[].field was a live MEMBERSHIP channel — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew.

  1. The describe text (ruling item 1) states the split on ChartConfigSchema and on the widget's chartConfig, citing ADR-0021: the dataset decides which series exist and which column each one reads; chartConfig carries appearance.
  2. The by-name refusals (ruling item 2) live on a new per-carrier DashboardWidgetChartConfigSchema — ChartConfigSchema.extend(…), the ReportChartSchema spelling — which tombstones the four keys with retiredKey(). Each refusal names the dataset selection the intent belongs in.
  3. The ADR-0087 kit in PR fix(spec): retire page.assignedProfiles and answer profiles: with the permission-set route #17835's shape: the D2 conversion dashboard-widget-chart-config-structure-removed (dashboards only), the D3 semantic entry dashboard-widget-chart-config-structure-refused carrying the structured TODO, four RETIRED_KEYS_BY_MAJOR[18] entries, all registered through gen:migration-registry.
  4. A minor changeset with the **BREAKING** banner and a FROM → TO table.
  5. Regenerated artefacts — the repo's own generators only; nothing here is hand-edited.

⭐ The accept-set probe, before and after, on both faces

Run against the built dist at each state, exit codes captured before any pipe.

document before after
dataset-bound widget · chartConfig.type ACCEPT REFUSE at chartConfig.type
dataset-bound widget · chartConfig.xAxis ACCEPT REFUSE at chartConfig.xAxis
dataset-bound widget · chartConfig.yAxis ACCEPT REFUSE at chartConfig.yAxis
dataset-bound widget · chartConfig.series ACCEPT REFUSE at chartConfig.series
dataset-bound widget · appearance only, WITH type ACCEPT REFUSE (the type tombstone)
dataset-bound widget · appearance only, NO type REFUSE (type was required) ACCEPT ← the widening
dataset-bound widget · no chartConfig at all ACCEPT ACCEPT
inline-data chart · type ACCEPT ACCEPT
inline-data chart · type + xAxis ACCEPT ACCEPT
inline-data chart · type + yAxis ACCEPT ACCEPT
inline-data chart · type + series ACCEPT ACCEPT
inline-data chart · all four at once ACCEPT ACCEPT
report chart · type + xAxis/yAxis as dataset names ACCEPT ACCEPT
report chart · + series ACCEPT ACCEPT

The inline-data arm is unchanged in every row, which is the half ruling item 1 requires and the half a tombstone on the shared ChartConfigSchema would have silently broken.

⚠️ On "an inline-data widget", as the acceptance list words it. There is no such thing on this face, measured rather than assumed: DashboardWidgetSchema.dataset is required, so a dashboard widget is dataset-bound by construction. The inline-data face is the react tier's ObjectChart with a data binding, whose contract IS ChartConfigSchema (react-blocks.ts sets schema: ChartConfigSchema and publishes all four keys in its dataProps) — which is the face the ruling's own wording names ("On an inline-data CHART"). Rows 8–12 are that arm; rows 13–14 add the third carrier for completeness.

⭐ The one widening, and why it is not optional

ChartConfigSchema.type is REQUIRED. Before this change a chartConfig with no type was refused as incomplete — on every face. Ruling item 2 refuses type on the widget; ruling item 1 says chartConfig still carries appearance there. Both can hold only if absence becomes legal on that carrier, which retiredKey() (a z.never().optional()) does. So exactly one document class moves from refused to accepted: chartConfig with no type on a dashboard widget.

⛔ The alternative is not a narrower reading, it is a contradiction: leaving type required while refusing every value makes chartConfig UNAUTHORABLE on a dashboard widget, which deletes the appearance channel ruling item 1 grants in the same sentence.

Reverse verification — the refusal can fail

One-shot, on the committed tree, with the mutation proved on disk by blob hash rather than by an editor's exit code, and restoration proved by hash equality plus an empty git diff HEAD.

  • Predicted direction: turns red. Observed direction: turns red.
  • Mutation: drop the xAxis tombstone from the widget carrier. Anchor occurrences 1 → 0, injected marker 0 → 1, blob 919d93f07fa8 → 1f57132a1ace.
  • MUTATED: vitest run src/ui/dashboard-chart-structure-refusal.test.ts exit 1 — 9 failed / 6 passed.
  • RESTORED (git checkout HEAD -- PATH, never a bare checkout, under a trap with absolute paths): blob back to 919d93f07fa8, git diff HEAD empty, exit 0 — 15 passed.

No dist leg: the pin imports ./dashboard.zod from source inside its own package, so there is no exports resolution to preflight.

Two door verdicts MOVED, and they are re-pinned rather than relaxed

chart.zod.ts's header claimed a BFS from every metadata root reaches all five chart shapes. Re-measured on this branch, that sentence is now false and is amended in place:

ChartSeriesSchema / ChartAnnotationSchema / ChartInteractionSchema stay direct, which is what keeps the assertion from being satisfiable by a broken walker.

What this costs, stated rather than discovered

The four keys carried presentation alongside the binding — axis titles, formats, bounds, grid lines, log scale, and per-series labels, colours, stacking and mark types. Refusing the keys takes the presentation with the binding. The combo chart a dataset-bound widget could author through series[].type has no authoring channel on this face any more. That is ruled, not incidental: the option that kept it was on the table and was not taken. The showcase's combo_count_vs_progress widget records the loss at its site.

The changeset level

minor, not the major ruling item 3 asks for, and the authority was re-read on this branch's own base rather than inherited:

Verification

  • pnpm --filter @objectstack/spec test — 502 files / 14672 tests, exit 0.
  • pnpm --filter @objectstack/lint test — 106 files / 3996 tests, exit 0.
  • pnpm --filter @objectstack/metadata-protocol test — 182 files / 2596 tests, exit 0.
  • pnpm --filter @objectstack/dogfood test — 137 files / 1103 tests, exit 0.
  • typecheck: @objectstack/spec, @objectstack/lint, and all three example apps, exit 0.
  • validate (the parse door) on all three example apps, exit 0.
  • eslint --no-inline-config over the WHOLE repo — 6932 files judged, 0 messages, exit 0. Not a narrowed run, so no narrowing claim is owed.
  • The derived gate family: dispatch-gates.mjs --commands on this tree, 116 families, each exit captured before any pipe and reconciled with --ran. 110 exit 0; 6 exit 3 = PREREQUISITE NOT MET in a partially built tree (NOT MEASURED, not red) — check:doc-formula-expressions, check:doc-security-posture, check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt, each needing a full repo build. check:durability-log-level entered the family on the final head and was run separately, exit 0. The ratchet family was re-run on the final commit after the merge.
  • origin/main was merged through scripts/pm/os-regen-merge.sh (merge committed BEFORE regeneration) and the registries were diffed against origin/main afterwards: 0 entry ids lost, exactly the 2 this PR adds.

skills/** budget

This PR touches one published-skill file and it is generated (gen:react-blocks). Net change is zero lines on all three readings: the changed file 115 → 115; every SKILL.md in skills/ summed, 6145 → 6145; the whole skills/ tree, 12766 → 12766. Three table cells were rewritten in place.

维护者速读(草稿)

改了什么。 仪表盘上的图表,数据从「数据集」自动来。以前作者还能在 chartConfig 里自己写「画哪几条线、每条读哪一列、画成什么图」—— 这跟数据集说的可能不一样,而且不一样的时候没有任何提示:图照画,数字悄悄换了一列。现在这四个键在仪表盘部件上被按名字拒收,每条拒收都告诉作者该去哪里写(部件自己的 type、dimensions、values)。外观(标题、颜色、高度、图例、数据标签、标注、交互)仍然归作者。

为什么改。 执行 2026-09-12 决策批次 #121 第 1 项您的裁定「同意」(C+D 合并)。方向不是本轮重开的。

风险与代价(含回滚)。 ① 有能力损失,且是裁定过的:数据集绑定的部件上,「这个指标画柱、那个画线」的组合图没有任何写法了;showcase 里那个组合图部件已就地记录。② 轴标题、数字格式、坐标轴上下界、每条线的颜色/标签也一并没了 —— 这些现在由数据集自己的维度/度量声明决定。③ 接受集有一个方向相反的变化:chartConfig 里以前必须写 type,现在不写才合法 —— 这是裁定本身逼出来的,不是选出来的,正文里有完整推导。④ 已存的仪表盘不会读崩:ADR-0087 转换会在读取时自动剥掉这四个键。⑤ 回滚 = 还原这一个 PR;没有数据迁移,没有不可逆动作。

席位意见。 (待补)

你要做的。 这个 PR 的 diff 碰到了 skills/ 下一个生成文件,按 Prime Directive #14 属于 Tier H 受管面 —— 落地需要您的一句话。净增 0 行(115 → 115),内容是三行表格文案随 schema 描述重新生成。

Acceptance notes

  • noted, not filed: packages/lint's chart-field-unknown still fires on a legacy document carrying the four keys, which is correct — but the parse door now refuses those documents outright, so whether the advisory is still reachable depends on whether lint runs before or after the parse on each entry path. Not measured here; the hints were corrected to say delete-and-migrate either way. 承接者:the next card on validate-widget-bindings.ts, whose rule inventory this belongs to.
  • noted, not filed: .claude/skills/spec-property-retirement/SKILL.md §4 still instructs @objectstack/spec 用 major for a retirement changeset, which ADR-0087's 2026-09-13 amendment supersedes. Reported in the round report with dedupe words rather than filed from here — it is a governed .claude/** surface and a seat's write.
  • noted, not filed: pnpm --filter PKG build --concurrency=2 forwards the flag into each package's own script; packages/cli's shell-if build script then dies with sh: 1: Syntax error: word unexpected. Hit once in this round and worked around with --workspace-concurrency=2 placed BEFORE the filters. Already documented as a toolchain trap; no code change is owed. 承接者:无 —— it is a caller-side usage error, not a repo defect.

Generated by Claude Code

…hboard widget

On a dataset-bound widget the dataset decides which series exist and which
column each one reads (ADR-0021); `chartConfig` carries appearance. The four
structure keys — `type`, `xAxis`, `yAxis`, `series` — are refused by name on
the widget through a per-carrier `DashboardWidgetChartConfigSchema`, each
refusal pointing at the dataset selection the intent belongs in. The base
`ChartConfigSchema` is untouched, so the inline-data react `<ObjectChart>`
tier keeps all four.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
… chart-structure refusal

The D2 conversion strips the four keys from stored dashboard widgets, the D3
semantic entry carries what they were saying (the intent lands in a selection
the item does not hold, so no walker can move it), and the four tombstones
register under `ui/DashboardWidgetChartConfig`. The liveness rows stay and are
regraded `dead` — the tombstone route keeps the key in the walked shape.

Two door verdicts moved and are re-pinned rather than silently relaxed:
`ChartConfigSchema` is now reached only through its two `.extend()` carriers,
and `ChartAxisSchema` reaches no metadata root at all.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…ngeset

`chart-config-missing` advised writing `chartConfig.series[].type`, which the
schema now refuses — a lint rule and a parse door contradicting each other. The
advice is withdrawn rather than reworded: after the ruling there is no authoring
channel for a per-series mark on a dataset-bound widget, so there is nothing a
`combo` author could do about the finding. The rule ID stays exported so an
existing `suppressWarnings` entry keeps parsing.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…ct after the merge

Step 4 of the os-regen-merge sequence, on its own commit so a reviewer can read
"what main brought" apart from "what the change produces". The only movement is
the structure-key note's wording, shortened so the half that matters to a react
author survives the generated table's truncation.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…our new unauthorable columns

The publish-gate and runtime-gate boards authored `chartConfig.type` / `.xAxis`
/ `.yAxis` / `.series`, which the parse door now refuses. They keep an
APPEARANCE `chartConfig` rather than dropping the key, so the fixtures still
exercise it. The #17502 unauthorable-columns pin gains the four tombstoned
columns at the same depth as `chartConfig.aria`.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…artconfig-precedence-half-2

Resolved three modify/delete conflicts by taking main's DELETION:
`packages/spec/api-surface-declarations/` was reverted off main whole by
#19024 (the declaration-text snapshot is taken back and the 27 signature
hashes restored), together with its `check:`/`gen:` scripts. This branch had
only regenerated three of those files; with the artefact and its gate gone
there is nothing for those edits to be about.

⚠️ Committed BEFORE regenerating, per scripts/pm/os-regen-merge.sh step 3: the
os-regen driver exits 0 while silently dropping one side, so the regeneration
belongs in its own commit on a known-good base.

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

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/analytics.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx (via xAxis (literal, a string literal in DashboardWidgetChartConfigSchema; a string literal in apply; a string literal in summary), yAxis (literal, a string literal in DashboardWidgetChartConfigSchema; a string literal in apply; a string literal in summary))
  • content/docs/ui/dashboards.mdx (via opportunity_metrics (literal, a string literal in fixture), xAxis (literal, a string literal in DashboardWidgetChartConfigSchema; a string literal in apply; a string literal in summary), yAxis (literal, a string literal in DashboardWidgetChartConfigSchema; a string literal in apply; a string literal in summary))
  • content/docs/ui/react-pages.mdx (via xAxis (literal, a string literal in DashboardWidgetChartConfigSchema; a string literal in apply; a string literal in summary), yAxis (literal, a string literal in DashboardWidgetChartConfigSchema; a string literal in apply; a string literal in summary))
  • content/docs/ui/reports.mdx (via xAxis (literal, a string literal in DashboardWidgetChartConfigSchema; a string literal in apply; a string literal in summary), yAxis (literal, a string literal in DashboardWidgetChartConfigSchema; a string literal in apply; a string literal in summary))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/releases/v16.mdx (via DashboardWidgetSchema (symbol, a top-level const), validateWidgetBindings (symbol, a top-level function))
  • content/docs/releases/v17/17-1.mdx (via validateWidgetBindings (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 13 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/authorable-defaults/ui.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 136 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 be7382d77ee32f830e98e17a82b63947472a9eb0 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json be7382d77ee32f830e98e17a82b63947472a9eb0

⚠️ 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 be7382d77ee32f830e98e17a82b63947472a9eb0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fa534bd47a1dec4836d87c8e69c490a4581f7220

Isolated reviewer, domain:spec lane, card #17385 half 2 (decision batch #121 item 1, C+D). Every reading below was taken by this session in a detached worktree at the head sha (fetched refs/pull/19363/head at 13:55:56Z; origin/main = be7382d77ee is an ancestor), with a second detached worktree at be7382d77ee for every before/after pair. Nothing was adopted from the PR body, the dev report or the card without re-measuring it. The card thread was read to its last comment (5750235692, the Clause-②-correction) and the PR thread to its only comment (the docs-drift bot).

① Derived judgments

1. The Clause-② move is correct, and the widening is entailed, not chosen. packages/spec/src/ui/chart.zod.ts reads type: ChartTypeSchema, at both trees (base line 608, head line 675) with no .optional() and no .default(); packages/spec/src/shared/retired-key.ts defines retiredKey() as z.never({ error }).optional(). Accept-set probe run from source with tsx against DashboardWidgetSchema, a dataset-bound widget, at 14:00:11Z (head) and 14:00:26Z (base), exit 0 both, captured before any pipe:

document on a dataset-bound widget base be7382d head fa534bd
chartConfig: { type: 'bar' } ACCEPT REFUSE at chartConfig.type, prescription names the widget's own type
chartConfig: { xAxis } / { yAxis } / { series } (each with type) ACCEPT REFUSE at that key's own path, prescription names dimensions / values
chartConfig: { type: 'bar', title: 'Revenue' } ACCEPT REFUSE at chartConfig.type
chartConfig: { title: 'Revenue' } REFUSE at chartConfig.type ACCEPT — the widening
chartConfig: {} REFUSE at chartConfig.type ACCEPT — same class
no chartConfig ACCEPT ACCEPT

So one document class moves from refused to accepted, exactly as the correction says. On whether a third option exists: ruling item 2 refuses every value of type; ruling item 1 keeps chartConfig authorable for appearance on the same carrier; type was required. No object schema, and no JSON document, can satisfy "the key must be present" and "no value is permitted" at once, so any implementation of items 1 and 2 together makes the key's absence legal, and absence was refused before. I enumerated the alternatives rather than trusting the dev's dichotomy: keep type required and refuse every value makes the bag unauthorable (deletes item 1's appearance channel); .omit() the key on the carrier yields the identical widening minus the prescription; .default(…) on the carrier accepts a value (violates item 2) and still legalises absence; moving appearance to a new key contradicts item 1's wording and is a larger widening. There is no third option; the declaration does not move again. Corroboration from the kit itself: the D2 conversion's own after fixture is chartConfig: { title: 'Revenue by stage' }, and stripKeys on a type-only bag yields {} — both parse at head only because of this widening, so the migration is unbuildable without it. yes (narrowing) is the closed-pair spelling the Post-Task Checklist prescribes for a diff that widens and narrows; node scripts/check-adr-0087-registration.mjs on the head tree read [BREAKING+clause-②-narrowing] and exited 0.

2. The inline-data face is unchanged, and the premise correction is right. DashboardWidgetSchema.dataset at dashboard.zod.ts:988 is SnakeCaseIdentifierSchema.describe(…) with no .optional(); the probe row "widget without dataset" is REFUSE at dataset at both trees, so there is no inline-data widget to probe. The inline-data face is the react tier: react-blocks.ts:327 declares tag: 'ObjectChart' with schema: ChartConfigSchema, and the generated skills/objectstack-ui/references/react-blocks.md publishes xAxis / yAxis / series rows plus the explicit type dataProp. Probe on ChartConfigSchema standalone: type, type+xAxis, type+yAxis, type+series, and all four at once are ACCEPT at both trees; ReportChartSchema with type plus string axes, with and without series, ACCEPT at both trees. The chart.zod.ts diff against origin/main is header prose, TSDoc and .describe() text only — no key on the base shape changed type, optionality or default. No refusal fires on the inline face.

3. minor is the level, and the sixth point holds. docs/adr/0087-metadata-protocol-upgrade-contract.md:388 at head carries 「Amended 2026-09-13 (#18003) — the level half」 with the three bullets verbatim, including "A tombstone names the npm release it ships in, ⛔ never the protocol major". packages/spec/package.json is 17.4.0, PROTOCOL_VERSION is '17.0.0', and git ls-remote --tags origin shows @objectstack/spec@17.4.0 as the latest spec tag with no 17.5.0 — so the next minor is 17.5.0. The four tombstones (dashboard.zod.ts:691) say @objectstack/spec 17.5.0; lit control: page.zod.ts, view.zod.ts, metrics.zod.ts, tracing.zod.ts already carry removed in @objectstack/spec 17.5.0 tombstones. Prose saying removed in @objectstack/spec 18 in packages/spec/src counts 9 lines at main and 9 at head — this PR adds none. The registrations are numbered at protocol 18: four RETIRED_KEYS_BY_MAJOR[18] strings, conversion toMajor: 18 / retiredFromLoadPath: true (the same flags as pageAssignedProfilesRemoved and chartConfigAriaRemoved), semantic entry inside step18. node scripts/check-changeset-no-major.mjs exit 0. PR #17835's changeset is on disk as .changeset/16929-page-assigned-profiles-removed.md with '@objectstack/spec': minor. Level re-derived: minor, **BREAKING** banner, ADR-0087 marker present.

4. The capability loss is recorded in all four places, and in the place a consumer reads. Read at head: (a) examples/app-showcase/src/ui/dashboards/chart-gallery.dashboard.ts, the combo_count_vs_progress widget, carries the comment naming the lost per-series mark and the D3 entry; (b) packages/spec/src/migrations/entries/semantic/18.dashboard-widget-chart-config-structure-refused.ts acceptanceCriteria criterion (4) states axis titles, formats, bounds, grid lines and per-series labels, colours and mark types are gone and the combo channel has no authoring channel on this face; (c) packages/spec/liveness/dashboard.json series child note: "The per-series mark type that made a dataset-bound COMBO chart authorable goes with the key — a ruled cost"; (d) .changeset/17385-dashboard-chart-config-structure-refused.md under "What this costs" carries the bold sentence about series[].type, and that body is what changeset version compiles into the npm CHANGELOG.md. A fifth site, content/docs/ui/dashboards.mdx, carries the FROM → TO table and the same loss sentence. The example dashboards dropped showLegend: true, showDataLabels: false, showGridLines: true, logarithmic: false — measured as the schema defaults at chart.zod.ts:226/230/704/705, so those drops change no rendering.

5. Both door verdicts moved as stated, and the amended assertion is still falsifiable. packages/spec/src/ui/chart.test.ts now pins verdict(ChartConfigSchema) to derived-clone and verdict(ChartAxisSchema) to unreachable, keeping ChartSeriesSchema / ChartAnnotationSchema / ChartInteractionSchema at direct plus the rootCount above 20 and nodeCount above 1000 controls. In door-reachability.testkit.ts, unreachable is returned only when no reached def matches AND clone overlap is below DERIVED_CLONE_MIN_OVERLAP (0.5); derived-clone requires overlap at or above 0.5 — a measurement, not a label. A walker regression trips the three direct controls and the count floors; a re-opened axis door trips the unreachable pin. ReportChartSchema re-declares xAxis / yAxis as z.string() (report.zod.ts:31), so the "no metadata root parses an axis object" fact is real. Local run of chart.test.ts, dashboard-chart-structure-refusal.test.ts and shared/retired-key-migrate-sentence.test.ts: 3 files, 106 tests, exit 0 at 14:02Z.

6. The ADR-0087 kit matches #17835's shape and nothing was lost across the merges. Shape: a retired-keys/18.ui__DashboardWidgetChartConfig__{type,xAxis,yAxis,series}.ts file each exporting one 'ui/DashboardWidgetChartConfig:key' string (same form as 18.ui__Page__assignedProfiles.ts); a semantic/18.dashboard-widget-chart-config-structure-refused.ts with id / surface / replacement / reason / acceptanceCriteria (same fields as 18.page-assigned-profiles-audience-to-permission-set.ts); a D2 conversion with toMajor: 18, retiredFromLoadPath: true, scoped to dashboards only, fixture with expectedNotices: 4; liveness rows kept and regraded dead with the house REMOVED note, which is the tombstone route's discipline in the retirement skill's route table. Registry set-diff origin/main vs head: semantic ids 231 to 232, lost 0, added dashboard-widget-chart-config-structure-refused; retired-key strings 149 to 153, lost 0, added the four; conversion ids 118 to 119 registry entries, lost 0, added dashboard-widget-chart-config-structure-removed (a second grep hit, rev_by_stage, is the new fixture's widget id, not a registry entry). pnpm --filter @objectstack/spec check:migration-registry exit 0 (232 semantic, 199 retired-key, 181 retired-def); check:liveness exit 0 at 14:01Z. pnpm --filter @objectstack/spec build exit 0 (14:03:54Z) then check:generated exit 0 at 14:04:55Z: all 15 generated artefacts current — check:migration-registry, check:spec-changes, check:upgrade-guide, check:skill-docs, check:skill-refs, check:react-blocks (so the skills/** file is byte-exactly what the generator emits), check:authorable-surface, check:api-surface, check:export-origins, check:declaration-map, check:docs, check:strictness-ledger, check:liveness, check:test-typecheck, check:meta-url-spelling. The 14 source audits that aggregate deliberately does not run were not run here either and are not claimed.

7. CI on fa534bd, latest run per check name, read at 14:07:27Z. 33 distinct check names, latest run each: 22 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 8 still running, 1 failure. The one red is Spec property liveness (job 106089034241, completed 13:55:20Z): its log shows pnpm install --frozen-lockfile dying at 13:55:18Z on better-sqlite3's node-gyp fetch of the Node headers with ECONNRESET, before any gate ran — an infrastructure flake, not a reading of this diff. The same gate on this tree locally, pnpm --filter @objectstack/spec check:liveness, exited 0 at 14:01Z. A re-run of that job is owed and is the seat's act, not the reviewer's. Still running at posting time: Lint & Repo Gates, Test Core (1/6), Test Core (2/6), Test Core (3/6), Test Core (4/6), Test Core (5/6), Test Core (6/6), Type Check · workspace. Of the seven required contexts: Lint & Repo Gates in_progress; Build Core success; Temporal Conformance (live PG + MySQL) success; Governed Surface Queue Guard success; Dogfood Regression Gate success; Test Core shards and the Type Check jobs as listed above. Green means conclusion: success, so nothing here is claimed green while in_progress.

② Semver level

minor for @objectstack/spec and @objectstack/lint, with the **BREAKING** banner, the FROM → TO table and the adr-0087: registered … marker — re-derived from ADR-0087's 2026-09-13 amendment at head, the launch-window guard's exit 0, the 17.4.0 package and tag state, and PR #17835's landed changeset of the identical class. Clause-②: yes (narrowing) is the correct declaration: the diff narrows (four keys refused by name on one carrier) and widens (absence of type becomes legal on that carrier), and the widening is forced by the conjunction of ruling items 1 and 2 with a previously required key. Ruling item 3's major and item 5's no are both superseded by measurement, as the PM's correction 5750235692 records; the direction C+D is untouched.

③ Boundary flags

  • Governed path in the diff. skills/objectstack-ui/references/react-blocks.md is in the file list (generated by gen:react-blocks, 115 lines at both trees, three describe cells rewritten). Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14 holds regardless of size: this review is not an approval, and whether the path qualifies as a queue-leg-regenerated register row is the owning seat's determination, not the reviewer's.
  • Pinned objectui sibling, measured at .objectui-sha = 53ded82bf7a4. DatasetWidget({ widget }: { widget: any }), mergeAuthoredPresentation(derived, raw: unknown) and chartConfigPresentation(raw: unknown) carry no type coupling to spec's widget chartConfig, and zero production sources author the four keys on a widget (lit control: chartConfig 7 lines in DatasetWidget.tsx; dark control 0). So the Console Pin Gate build is unaffected; it was skipped on this PR because the console filter paths (.objectui-sha and five scripts) did not move, which is the filter working, not a gap. What does change: objectui's mergeAuthoredPresentation → axisPresentation / mergeAuthoredSeries path becomes metadata-unreachable on the dashboard face, and objectui's own DatasetWidget.comboPresentation.test.tsx authors keys the protocol now refuses. The liveness note names objectui#4044 as the carrier; that is the sibling's half.
  • The two open questions the dev raised are correctly deferred, and neither is answered here. ChartAxisSchema's strictness now that no metadata root parses it is a datasource 账本判定的 20 条死键至今无人处置:三个块整块无人读,其中 readOnly 让一个 shipped 示例的「只读副本」可写(ADR-0049 enforce-or-remove) #4583-class question about a shape that has a declaration and no parse — a lint-side card, not this diff. A D2-only lossless entry for type is an upgrade-guide precision question; the shipped single D2 plus D3 pair matches the ruling's singular "the semantic migration entry" and the fixture-disjointness contract. Both are the seat's to route.
  • Confirmed for the seat's filing: .claude/skills/spec-property-retirement/SKILL.md:291 reads Changeset —— @objectstack/spec 用 major at head, at origin/main and at the dev's base e3b3cdd2df3, contradicting ADR-0087's 2026-09-13 amendment and [Decision] 两条裁决援引同一个 launch-window convention,却给出相反的 changeset 等级(minor vs major)(重建自 #18003) #18064's adjudication. Governed .claude/**; a seat's write, dedupe words as the dev report gives them.
  • Not this PR's: the 9 removed in @objectstack/spec 18 prose lines in packages/spec/src pre-exist on origin/main (main 9, head 9) and are [finding] Six retiredKey() tombstones tell authors a key "was removed in @objectstack/spec 18" — a version that does not exist; the package is 17.4.0 and these ship on the launch-window minor route, where npm major and protocol major decouple #18021's, cited by the amendment itself.
  • Docs drift bot's five pages, read at head: validating-metadata.mdx:179/247, react-pages.mdx:457, reports.mdx:194 show report-chart or react-tier examples where the keys stay authorable; analytics.mdx:237 is historical prose about list charts. None teaches a refused document; dashboards.mdx carries the FROM → TO table.
  • Lint: chart-config-missing emission withdrawn, id kept exported; the dev's unmeasured question about chart-field-unknown reachability behind the parse door stays unmeasured here too, with the carrier the dev named.

Implemented-by: claude/issue-17385-chartconfig-precedence-half-2
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 20, 2026 14:18
@os-zhuang
os-zhuang enabled auto-merge September 20, 2026 14:18
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 8271c81 Sep 20, 2026
44 of 45 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-17385-chartconfig-precedence-half-2 branch September 20, 2026 14:48
os-elon-musk pushed a commit that referenced this pull request Sep 20, 2026
… queue ahead of it

This step's first queue build ejected the PR that added it, and the reason was
neither the tree nor a crash: `exit 2` is this gate's own FINDINGS code, and the
findings were real citations written by somebody else.

MEASURED on queue entry `a7109d1f08`. A queue entry is built on the GROUP's
base, which carries the entries AHEAD of it in the queue and has not landed on
`main` yet. `merge-base origin/main HEAD` therefore lands at the PUBLISHED tip:
`231283a6e` at 14:30:14Z, while the group's base `8271c81425` reached `main`
only at 14:47:59Z. Everything between the two read as "added by this change" --
15 file(s) / 16 citations judged, 3 unresolvable, and all three written by the
two entries ahead: `#6361` twice from `ada701220` (#19364), `#18003` from
`8271c81425` (#19363). Against the group's own base the same tree judges 0
file(s). The Governed Surface Queue Guard, in the same build, read
`merge_group.base_sha` and correctly saw 1 commit and 178 changed lines.

Two halves, and the second is not cosmetic:

1. `lint.yml` declares the base -- `OS_GATE_MERGE_GROUP_BASE_SHA`, the name and
   the expression this file already uses for that fact. It renders empty on
   `pull_request` and `push`, where the ref guesses are CORRECT and are kept: a
   PR's merge ref already contains the main it was computed against.
   The step is not `if:`-skipped on `merge_group` -- this file asserts that
   every gate step here runs there.

2. The gate verifies the base resolves before handing it to `git diff`. It did
   not: an unresolvable `--base` threw `fatal: bad object` and exited 1, a
   failed read wearing a code that is neither the clean answer, the findings
   answer, nor the refusal. It now refuses with PREREQUISITE NOT MET (exit 3)
   and names every spelling tried and what to pass instead. Half 1 alone is
   inert -- the pre-change gate ignores the variable entirely -- and half 1 is
   what makes an unverified base reachable, so both are required.

Firing controls, on the real queue tree with `origin/main` pinned to the
published main of 14:30:14Z: ref guess -> exit 2 with the three findings,
reproducing the ejection; declared base -> exit 0; declared base absent from
the checkout -> exit 3; `--base` absent -> exit 3 (was: uncaught throw, exit 1).
`--self-test` covers all of it: 66 -> 73 cases, 7 batteries, and the
`diff-scope` battery floor moves 6 -> 13 so the new cases cannot stop running
unnoticed.

Also corrects the gate docblock sentence this wiring falsifies ("Neither is
installed here").

Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Co-authored-by: Claude <noreply@anthropic.com>
os-steve pushed a commit that referenced this pull request Sep 20, 2026
…n/main

The origin/main merge (79d709d) brought in a new conversion registered at
protocol 18 (dashboard-widget-chart-config-structure-removed, #19363),
moving ALL_CONVERSIONS from 97 to 98 at this head. Re-counted at runtime
post-merge; the below-floor count (10) is unaffected since the new entry is
above the floor.

Co-Authored-By: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…estTimeoutMs at the one platform fetch site (objectstack-ai#19388)

Fixes objectstack-ai#18975

Clause-②: yes

Ruling of record: comment `5729479418` — director seat summon objectstack-ai#24, batch
objectstack-ai#159 item 5, maintainer 「同意」 2026-09-18T11:42Z, letter **实现**. Not
re-adjudicated here. **The spec declarations do not move**: the
connector schema keeps every key, every bound and every default it had.

## STEP ZERO — where the platform-owned connector fetch actually lives

Measured before anything was written, on `origin/main` = `be7382d77e`
(2026-09-20T14:05Z), re-confirmed after the merge to `ada70122`.

**The ruling's wrapper already exists.**
`packages/spec/src/shared/resilient-fetch.ts` — exported as
`resilientFetch` from `@objectstack/spec/shared` — is the platform's
outbound-HTTP call: it already gave every attempt a 30s timeout and a
bounded exponential backoff with jitter and `Retry-After` handling. So
"land the wrapper there once, no gateway, no new subsystem" was
satisfiable without building anything new.

What the connectors do with it, measured per package:

| package | the one call its handler makes | before this PR |
|---|---|---|
| `connector-rest` | `rest-connector.ts` `request()` |
`resilientFetch(...)` |
| `connector-slack` | `slack-connector.ts` `callSlack()` |
`resilientFetch(...)` |
| `connector-openapi` | `openapi-connector.ts` `request()` | a naked
`fetch` — unbounded, never retried |
| `connector-mcp` | handlers to `client.callTool` | no `fetch` at all;
the MCP SDK owns the transport, with a hardcoded 30s `timeout` |

So the fetch site is **one shared wrapper plus one bypass to fold in**,
not several independent paths and not something that needed
restructuring. The gap was never "there is no wrapper" — it was that the
wrapper could not express the declared policy, and that no authored
value could reach it: `ConnectorProviderContext` carried none of the
three keys.

Holder check at claim time: across all 30 open PRs, zero touch any file
matching `connector`, `resilient-fetch` or `service-automation` (lit
control on the same scan: `packages/spec` hits 9, 22 and 9 files on PRs
objectstack-ai#19364 / objectstack-ai#19363 / objectstack-ai#19335).

## What landed

**One wrapper, extended by exactly what was missing.**
`ResilientFetchOptions` gains `strategy`, `backoffMultiplier`,
`maxDelayMs`, `jitter` and `retryOnNetworkError`. **Each defaults to the
behaviour the wrapper already had**, so a caller that passes none is
byte-identical to before.

**One mapping.** `connectorFetchOptions()`
(`packages/spec/src/integration/connector-fetch-policy.ts`) is the
single place a connector's declared policy becomes wrapper options — one
execution site, not one per connector package.

**One contract widening.** `ConnectorProviderContext` gains
`retryConfig`, `connectionTimeoutMs` and `requestTimeoutMs`, read-only
and resolved: the materializer parses `retryConfig` through
`RetryConfigSchema`, so a factory reads real values instead of
re-deriving the schema's defaults. The policy also joins the instance
signature, so editing it re-materializes the connector instead of
leaving the old policy serving until restart.

**The built-in HTTP providers honour it by construction.** `rest` and
`openapi` pass the context's policy into their connector builders.

✅ **RESOLVED at 2026-09-20T17:02:28Z — the work is on the branch; the
push simply lagged the report by about two minutes.** Kept in full
rather than deleted, because the sequence is worth more than the tidy
version. At **17:00:04Z** the remote tip was `4432f967e3` with an
18-file diff and ⛔ none of the three files below; the dev's report
already described them at head `5911c8cf`. The seat held the review and
struck this paragraph. At **17:02:28Z** `git ls-remote` reports the tip
as **`5911c8cf664f534823d598c801f852c346be8075`** — `5911c8cf
docs(spec): the connector header and SYNC_ARCHITECTURE describe the
implemented behaviour` sitting on top of `4432f967` — **21 files**, all
three present. ⇒ the 17:00Z reading was **true when taken** and is now
superseded; the report was accurate about content and early about the
push. ⭐ The rule that survives, and it is not 「the check was wasted」:
**`git ls-remote` is the authority and the PR object is not** — while
this was being checked the PR object was still serving the stale 18-file
count. ⛔ A conclusion drawn from a summary face has a shelf life; one
drawn from the ref does not.

**The teaching text objectstack-ai#18794 narrowed is corrected to describe the
implemented behaviour** — `packages/spec/docs/SYNC_ARCHITECTURE.md` in
**five** places, plus the `connector.zod.ts` header TSDoc it renders
from (`content/docs/references/integration/connector.mdx` follows by
`gen:docs`, ⛔ never hand-edited). Those passages asserted the keys were
「declared but currently unimplemented」 and that
`ConnectorProviderContext` could never carry them; **both are now
false**. This is the ruling's third bullet, ⛔ not an absorption of
objectstack-ai#18794. ⭐ `health.circuitBreaker` and `connectionTimeoutMs` are
explicitly kept named as **still inert** in every corrected passage.

⚠️ ⭐ **Found by hand, ⛔ not by the drift bot — and it is the bot's own
declared blind spot doing exactly what it warns about.**
`SYNC_ARCHITECTURE.md` states the rule by its **inputs**, so it shares
no identifier with the emitter this diff changed and ⛔ no run could ever
have listed it. The bot's three named pages were each hand-verified and
**two were ACCURATE and left untouched** — `error-catalog.mdx`'s
`no_retry` is the API error-envelope enum from `api/errors.zod.ts`, a
different enum this diff never touches, and `jobs.mdx` is
`job.retryPolicy` from `shared/retry-policy.zod.ts`, likewise untouched.
The third was accurate too, and it is the one that falsified the code.

**Two interpretive calls, both stated rather than assumed:**

- 🔴 **`maxAttempts` counts TOTAL calls, the first included** — the
contrast `content/docs/automation/flows.mdx` already draws against
`maxRetries`, and it is what **corrected this implementation**. ⚠️
**Replaced by the seat 2026-09-20T17:00Z.** This bullet previously read
「counts **retries**, not total calls」, reasoning from `min(0)` and a
`shared/retry-policy.zod.ts` comment the dev has since said it
**over-read** (that comment is about opt-in vs opt-out defaults, ⛔ not
the counting base). The first reading reached a pushed commit; it was
falsified by a **documentation page**, and the implementation was
changed to match the page — ⛔ not the other way round. New pin:
`maxAttempts: 3` must make **three** calls, ⛔ not four, the case that
tells the two readings apart. Ablation: restoring `+ 1` turns 3 mapping
tests red.
- `maxDelayMs` is applied **after** jitter. Jitter is additive, so
capping first would let a delay land up to 99ms above the declared
ceiling.

## The seat's assumption 4 is falsified, and that is the one thing the
ruling asked me to report rather than invent

`AbortSignal.timeout` **is** available (Node 22 or newer, which the root
`engines` field pins; already used at
`packages/drivers/driver-turso/src/turso-driver.ts`). The
connection-vs-request distinction is **not**.

A connector's call is a WHATWG `fetch`, whose only cancellation surface
is one `AbortSignal` over the whole operation; nothing in that interface
observes the connection phase separately. Bounding time-to-response with
`connectionTimeoutMs` would kill a slow-but-connected upstream that the
author meant to allow with a large `requestTimeoutMs` — breaking the
very promise the key makes. Node's undici exposes `connectTimeout`
through a custom dispatcher, which is Node-only and a new subsystem
underneath every connector: the same ruling forbids it.

So `connectionTimeoutMs` is **carried** onto `ConnectorProviderContext`
(a custom provider on a transport that can separate the phases may
honour it) and **not enforced** by the platform.
`packages/spec/liveness/connector.json` keeps that one row `dead`, with
the measurement written into it, and a pin in
`connector-fetch-policy.test.ts` goes red if anyone aliases it onto
`timeoutMs`. **Nine of the ten rows flip, not ten.** The tenth is owed a
second, narrower ADR-0049 decision — see the acceptance notes.

## Verification

Full pipeline at the final commit `956fdb10`.

**Gate family**, re-derived in this worktree from the real changed paths
(`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`), every command run with its exit code
captured before any pipe, then reconciled with `--ran`:

```
dispatch-gates --ran: 86 derived family(ies) accounted for — 83 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3).
```

The three NOT MEASURED are `PREREQUISITE NOT MET`, not findings:
`check:dual-build-cjs-loads` and `check:type-check-debt` both need a
whole-repo build closure (CI builds it before those steps), and
`check-plugin-teardown-shape.mjs --self-test` cannot reach its
commit-pinned positive control on a shallow clone. All three are CI's to
run.

**Tests** (all on the merged tree):

```
@objectstack/spec                503 files / 14711 tests  passed
@objectstack/service-automation  140 files /  1676 tests  passed
@objectstack/connector-openapi     4 files /    34 tests  passed
@objectstack/connector-rest        4 files /    24 tests  passed
@objectstack/connector-mcp         3 files /    23 tests  passed
@objectstack/connector-slack       3 files /    10 tests  passed
```

`typecheck` green for all six. `pnpm --filter @objectstack/spec
check:generated`: 15 of 15 artifacts up to date (`api-surface/` and
`export-origins/` regenerated after a real build — the two new exports
plus the `ResilientFetchOptions` re-export).

**Lint — measured whole, not narrowed.** `eslint . --no-inline-config
--format json` over the repo root: **6939 files, 0 errors, 0 warnings**,
exit 0.

**Ablation — both pins proved able to fail**, through
`scripts/ablation-replace.mjs` (anchor must hit, blob hash must move,
restore proved against `HEAD`):

| mutation | result |
|---|---|
| `connector-fetch-policy.ts`: invert the early return so a declared
`retryConfig` is never mapped | 9 of 10 mapping tests RED, restored blob
== HEAD |
| `rest-provider.ts`: stop passing `ctx.retryConfig` into the connector
| 4 of 5 provider retry pins RED, restored blob == HEAD |

The pins assert **call counts and delay sequences**, not the presence of
a field: a pin on the def's `retryConfig` could not have failed here,
because the key was already storable and served back before any of this
landed. The sharpest one is the narrowing case — an authored
`retryableStatusCodes: [429]` must leave a 500 unretried, which only
passes if the authored list is the one executed (500 is retryable under
both the wrapper's own default and the schema default).

## Acceptance notes

**To file (class (c), an authoring trap that survives this PR):**
`connector.connectionTimeoutMs` still parses, still stores, is still
served back by `/meta/connector`, and is enforced by nothing — for the
measured reason above, which is a property of `fetch`, not an omission
here. It now needs a decision this card's ruling did not answer: retire
it, or re-describe it as something the platform can enforce (its sibling
`requestTimeoutMs` already is). Reproduction: declare a `connectors:`
entry with `provider: 'rest'` and `connectionTimeoutMs: 1000`, point
`providerConfig.baseUrl` at an endpoint that takes 5s, and dispatch the
`request` action — it completes normally. Dedupe words:
`connectionTimeoutMs declared unenforced` · `connector connect timeout
AbortSignal fetch` · `ADR-0049 connectionTimeoutMs second decision` ·
`connector.json connectionTimeoutMs dead row` · `retire or redescribe
connect timeout`.

**Fixed in place, declared here rather than filed:**
`connector-openapi`'s generated actions went through a naked `fetch` —
unbounded, never retried, and the one built-in HTTP path an authored
policy could never reach. It is the same defect on the same measured
site as this card's, the fix is mechanical and its shape was already
pinned by two sibling connectors, no other open PR holds the file, and
it adds no new gate family. Those actions now go through the same
wrapper as `connector-rest` and `connector-slack`. Evidence:
`openapi-connector.ts` `createOpenApiConnector` — `doFetch(url, init)`
became `resilientFetch(url, init, fetchOptions)`;
`@objectstack/connector-openapi` 34 tests still pass.

**Noted, not filed:**

- `ConnectorProviderContext.icon` and `.type` are set by the
materializer and read by none of the three shipped provider factories,
so an authored `icon:` or `type:` on a declarative instance never
reaches `GET /api/v1/automation/connectors`. Already recorded per-row in
`packages/spec/liveness/connector.json`, with what is owed already
stated there. Next toucher: whoever adds or changes a provider factory.
- `connector-slack` ships no provider factory, so nothing authored can
reach it — only the plugin door, hand-wired by its host. Not silent: an
unknown `provider` is a loud, named boot failure that lists the
installed ones. Next toucher: whoever adds a `slack` provider key.
- The `connector-rate-limit-config-removed` comment in
`packages/spec/src/conversions/registry.ts` says `retryConfig` and the
timeouts "are live". It was wrong when written (the ledger's
`retryConfig.strategy` row corrects it by name), and this PR makes nine
tenths of it accidentally true. A stale comment, no behaviour. Next
toucher: whoever edits that conversion entry.
- `health.circuitBreaker`'s sub-keys are `dead` on the same schema and
the same ADR-0049 worklist. Out of this card's scope by the card's own
words ("本卡只管这三个"), and its teaching text was already stanched by objectstack-ai#18983.
Next toucher: the next ADR-0049 connector sweep.
---

## Round 2 — both at-tier FAIL items fixed, at head `4a9b3480f2`

Review record `5751411253` FAILed this PR on two items. Both are fixed,
pinned and ablated; ⛔ nothing else was widened, and the two optional
notes the review offered (the `.describe('Maximum retry attempts')`
counting-base wording, and the pre-existing
`Retry-After`-on-any-retryable-status and unbounded-body-read
observations) were **deliberately not acted on**.

### FAIL 1 — the openapi routing is now pinned

The review's ablation proved this PR's own justification false: 「shape
already pinned by two sibling connectors」 did not hold for **this file**
— restoring the naked fetch left **34/34** openapi tests green.

Two cases added in `openapi-provider.test.ts`, through the factory with
`retryConfig` on ctx, mirroring `rest-provider.test.ts`: scripted fetch
`[503, 200]`, `{strategy: 'fixed_delay', maxAttempts: 2, initialDelayMs:
100, retryableStatusCodes: [503], jitter: false}`, asserting **exactly
2** upstream calls and a 200. The review's own ablation reproduces on
the same blobs (`a0172843ccb9` → `22b1470c9170`) and now turns the retry
pin **RED** where it measured 34/34 green; restore proved blob == HEAD.

⚠️ **Precision, stated rather than glossed: only 1 of the 2 new cases
discriminates.** The narrowing case cannot — with a naked fetch nothing
retries, so 「1 call」 is what **both** trees produce. It is kept for what
it pins, ⛔ not as a revert detector.

### FAIL 2 — `maxDelayMs` is now a maximum. Route (a), and the reason

The review offered two routes. **Route (a)** was taken: a `Retry-After`
longer than `maxDelayMs` now **ends the retry loop and returns the
response**.

⭐ **Why (a) and not (b):** this card exists to make a declaration equal
its enforcement, so making 「Maximum retry delay in ms」 **true** beats
documenting an exception to it. Route (b) would have left a key whose
*name* says maximum with an upstream-controlled way past it — which is
the exact shape **objectstack-ai#19410** was filed for earlier today.

The three alternatives, and why returning wins: sleeping it out makes
the key **not a maximum**; retrying sooner than asked is the abuse
`Retry-After` exists to prevent; **returning** hands the caller the real
status and its header. Only a `Retry-After` can reach that branch,
because `backoffMs` caps its own output — so the review's jitter-cap lit
control is untouched.

**Pinned** at `maxDelayMs: 1000` + `retry-after: 3600` → 1 call, the 429
returned, `sleep` **never called**, with a control that a `Retry-After`
**within** the ceiling is still honoured and still retried.
**Ablation**: deleting the guard (`bab28fcb1bde` → `9564b3126ef7`) turns
it RED; restore proved blob == HEAD.

⇒ the `retryConfig.maxDelayMs` ledger note **and** the changeset
sentence were both corrected, so ⛔ no artefact still claims a ceiling
the code ignores.

### Verification at the pushed head

Gate family re-derived on the pushed tip **and again** on the fix commit
— **identical 109 families** both times: **107 green / 2 NOT-MEASURED /
0 red / 0 unrun**. The two NOT-MEASURED are the shallow-clone self-test
and `check:dual-build-cjs-loads` needing the full build closure — ⛔ exit
3 is a prerequisite, ⛔ not a red. `check:generated` 15/15 with **no
regeneration owed** (route (a) moved no `.describe()`, so
`connector.mdx` did not move). Tests: openapi **36** (was 34), rest 26,
slack 10, spec wrapper+mapping 29. Full-repo lint 6,945 files, **0
errors, 0 warnings**.

⏹ ⚠️ **Overtaken and corrected 2026-09-20T20:37Z — the merge WAS
taken.** The paragraph below was true when written and is spent; kept
struck rather than deleted, because the reason it gave is the reason
round 3 exists.

> ~~`origin/main` has moved 12 commits since this branch's single merge
of record (`a88a9733`). It was **deliberately not re-merged**: the
at-tier record is keyed to a head sha, and a fresh merge creates a head
the record does not name. The merge is taken when the review is clear, ⛔
not while it is in flight.~~

---

## Round 3 — `origin/main` merged, the head re-reviewed, and the
base-drift cost paid

Once round 2 cleared, the base was **21** commits stale and the landing
needed a fresh CI run, so `origin/main` `576d5df6` was merged once as
**`13987f1b`**. ⛔ No rebase, ⛔ no amend, ⛔ no force-push, ⛔ no empty
commit.

**The merge is provably automatic**: `13987f1b` has exactly two parents
(`4a9b3480`, `576d5df6`), and `git merge-tree --write-tree 4a9b348
576d5df` yields tree `8350d10d`, which **equals** `13987f1b^{tree}` ⇒
no hand resolution existed. The two sides are disjoint — this PR 22
files, main 83, intersection **0** (lit control: both lists non-empty).

**This PR's own delta did not move**: 22 files, +1197/−132, the same
file list as before the merge.

**Neither guard was undone.** Both blobs are byte-identical to round 2,
and both ablations still give **exactly 1 red** — the openapi routing
pin (35 passed) and `maxDelayMs bounds a Retry-After by STOPPING` (18
passed, the within-ceiling and jitter-cap controls green by name).
Restores proved blob-equal to HEAD.

**Gates: 110 derived / 109 green / 1 NOT-MEASURED / 0 red / 0 unrun.**
One family *appeared* with the incoming commits
(`check:issue-citations`, wired in by `5a5e710f`); a true set comparison
against a round-2 derivation gives only-in-r3 = that one, only-in-r2 =
none. The NOT-MEASURED is `check-plugin-teardown-shape --self-test` at
exit 3 — a shallow-clone prerequisite, ⛔ not a red. `check:generated`
15/15 with no regeneration owed, the migration registry included after
main deleted ten entry files. Whole-repo lint 6,943 files, 0 errors, 0
warnings.

⭐ **Honest cost, stated because the reviewer found it and the dev's
number alone would have hidden it**: the first gate reading on a
turbo-cache-restored closure was **108 green + 2 exit 3**, not 109 + 1 —
`check:type-check-debt` refused on a dist whose mtimes predated its
sources, and reached exit 0 only after a real rebuild. Same conclusion,
named with what it cost.

**CI on `13987f1b`: 35 names, 33 success + 2 skipped, 0 failing, 0
pending** — all six `Test Core` shards green, including the 2/6 shard
that was red before. ⚠️ A green re-run **corroborates** that the earlier
red was not this PR's; it ⛔ does not prove it. The load-bearing evidence
is still the mechanism filed as **objectstack-ai#19424** (`80 × 0.25 s = 20 s` against
an observed 20,999 ms, the same titled assertion passing in 299 ms in
the same run).

⭐ **And one thing this PR previously could not establish, now
established from a different door**: `GET /branches/main/protection`
answers **403**, but `GET /rules/branches/main` answers **200** and
lists seven required contexts — `Test Core` among them, all seven
`success` here. **A 403 on one endpoint is a fact about that endpoint, ⛔
not about the question.**

**Round-3 at-tier review: PASS** — record `5752480809`, keyed to this
head. `check-clause2-carriers --pair 19388` exits **0** with zero ✗
rows, run *after* the record existed.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rd package body stages, and stop the record under-reporting functions (objectstack-ai#19373)

Fixes objectstack-ai#17518

Clause-②: yes

Executes ruling **A′** — decision batch objectstack-ai#192 item 3, comment 5748934194,
maintainer 「192 同意」. Its two steps, its refusals (A and B) and its
fences are followed as written; every place where the tree made me read
the ruling rather than transcribe it is called out below.

Base of every reading in this body: regeneration commit `96dd3549ff6`,
the head of the SIXTH merge.

> ⚠️ **The readings below were brought to this head by the seat, not by
the round that first wrote them.** Two merge rounds have run since the
first draft. Each figure corrected here is named in the correcting
round's own report on card objectstack-ai#17518 — comment 5750725852 for the first,
5750987577 for the second — and the seat re-verified the head, the
regenerated index and mergeability itself before editing. Anything not
listed in those two reports is the original round's reading, unchanged.

## The confidence gap the ruling asked me to close first

「whether `effect` is required or defaulted on the declaration schema —
read it, ⛔ do not mint a value」

**Defaulted.** `FlowFunctionDeclarationSchema.effect` is
`FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` where
that constant is `'pure'` (`automation/flow-function.zod.ts`). Measured,
not read off the source alone:
`FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' })`
succeeds and yields `{ handler: 'x', effect: 'pure' }`. The array member
of `functions` states `FlowFunctionEffectSchema.optional()` with **no**
default, so the two forms differ and neither is restated anywhere in
this diff — each JSON stage inherits its form's own optionality by
deriving from it.

That reading is what the producer writes: the bare-callable
normalisation uses `DEFAULT_FLOW_FUNCTION_EFFECT` and the array form
gets nothing.

## What landed

**`packages/spec/src/automation/flow-function.zod.ts`** —
`FlowFunctionLoweredDeclarationSchema` is exported (step 1), with its
`FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a
module-local `const`, and `automation/index.ts`'s `export *` only
re-exports what is already exported.

**`packages/spec/src/stack.zod.ts`** — two new bodies **beside**
`AssembledPackageBodySchema`:

- `ArtifactStagePackageBodySchema` — the on-disk artifact stage.
`functions` entries are the lowered spellings, `hooks[].handler` is a
string.
- `RecordStagePackageBodySchema` — the registry record stage: literally
`ArtifactStagePackageBodySchema.extend({ functions: … })` with
`functions[].handler` optional in both the map-record form and the array
form, and nothing else.

`AssembledPackageBodySchema`, `composeStacks` and the `cannot drift`
invariant are ⛔ untouched: those callables are live on the stage the
assembled body declares itself for, and narrowing it would refuse a
published composition function's own output. Both new schemas carry the
same structural `z.ZodType` annotation as the assembled body, for the
two reasons recorded there (TS7056; a named alias turning `stack.zod`
into a shared chunk).

**`packages/spec/src/api/package-api.zod.ts`** — the installed-package
row's `manifest` is rebound to the record stage (step 1). The
`z.unknown()` override and the docblock defending it are gone, and the
sentence that ruling A step 5 assigns to this edit is corrected in
place: those two members are **not** why `ArtifactPackageSchema` and
`ObjectStackDefinitionSchema` publish no JSON Schema —
`src/stack.zod.ts` is not one of the subpath namespaces
`build-schemas.ts` walks, so neither is ever reached by the emit loop.

**`packages/objectql/src/registry.ts`** — step 2.
`withDeclaredFunctionEntries` rewrites a bare callable `functions` map
entry to `{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }` at the
assembly boundary, before `toRecordManifest` runs. `toRecordManifest`'s
structural rule is ⛔ untouched and no key is special-cased inside the
projection; the two spellings are simply made structurally equal ahead
of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest
is never mutated and a copy is made only when an entry really needed
rewriting.

## Two places where I read the ruling rather than transcribed it — both
stated so they can be overruled

1. **「`functions` entries the lowered declaration」 is implemented as
BOTH lowered members of `FlowFunctionEntrySchema`**, not only the record
one. `objectstack build` emits `{ myFn: 'myFn' }` for a bare entry and
`{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage
admitting only the record form would refuse artifacts this repo really
writes — the failure mode that withdrew letter B, one key across. Ruling
A′'s own step-4 control names both shapes (「a string and a lowered
record」). Measured: the artifact stage accepts a body carrying one of
each.
2. **The array member is transcribed, not derived.** `functions`' array
branch is declared inline inside the assembled body's own shape, and
narrowing it in place is the one thing this pair may not do. The
transcription's drift is guarded instead:
`stack-json-stage-package-body.test.ts` pins the authoring array entry's
key set equal to both JSON stages', so a key added there and not here
reddens by name.

## Acceptance, as ruling A′ lists it

| criterion | result |
|---|---|
| both bodies convert under `z.toJSONSchema` (self-test over the whole
body) | **YES** / **YES**; control: the assembled body still **NO**
(`Function types cannot be represented in JSON Schema`); probe controls
lit `z.string()` YES, dark `z.object({a: z.function()})` NO |
| the showcase-shaped manifest (`config.ts:244-249`) reports **2**
functions on the `GET /packages` row, the bare one as a handler-less
declaration | **2**:
`{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}`,
driven through the real `SchemaRegistry.installPackage` |
| `hooks` unchanged | unchanged: an inline handler is dropped (the key
is optional and admits that), a string handler survives verbatim. The
array `functions` form also keeps its entry:
`[{"name":"syncBilling","effect":"writes"}]` |
| `AssembledPackageBodySchema` / `composeStacks` / the invariant
untouched | untouched — no edit in those regions;
`assembled-package-body.test.ts` and
`compose-stacks-manifest-preserve.test.ts` stay green |
| the two `noted, not filed` corrections in the same edit | baseline
reason line: made TRUE by step 1 rather than reworded —
`automation/FlowFunctionLoweredDeclaration` is now in
`json-schema.manifest/automation.json`, so 「the lowered record …
publishes normally」 is now a fact. `package-api.zod.ts` docblock last
sentence: corrected in place, see above |

Stage separation, measured rather than asserted: the record stage
accepts the handler-less declaration and the **artifact** stage refuses
it; the assembled body accepts a live callable and **both** JSON stages
refuse it; both JSON stages still refuse an authoring glob and an
unknown key (`namesapce`). So the two keys moved from `unknown` to a
declaration, and nothing else moved.

## Reverse verification — two ablations, each restored with proof

Both ran against committed code, each with a `trap` restore, an on-disk
landing proof (anchor `grep -c` before/after plus a blob-hash change)
and a restore proof (`git hash-object` back to the HEAD blob, `git diff
HEAD` empty).

- **A1 — remove the producer normalisation**
(`toRecordManifest(withDeclaredFunctionEntries(manifest))` →
`toRecordManifest(manifest)`; anchor 1→0, injected 1, blob `b0af60d7…` →
`17b7c93c…`): `registry-package-manifest-serializable.test.ts` goes **1
failed / 15 passed**, naming the exact defect — `expected [
'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1)
]`. Restored blob `b0af60d7…`, diff empty.
- **A3 — collapse the record stage into the artifact stage**
(`jsonStageFunctionsKey(true)` → `(false)`; anchor 1→0, injected 2, blob
`60c13b43…` → `822bed8e…`): **2 failed / 79 passed** across two files —
`record accepts the handler-less declaration; ⛔ the ARTIFACT stage
refuses it` and `parses a row carrying the residual the projection
really produces`. So the one-key difference that IS the fourth stage is
load-bearing in both packages' pins. Restored blob `60c13b43…`, diff
empty.

No ablation is offered for 「both bodies convert」: that claim already
carries its discriminating control inside the same test file (the
assembled body must NOT convert), which is a lit/dark pair rather than
an assertion about itself.

## Tests and gates

All through `scripts/pm/os-verify-lock.sh` with
`OS_VERIFY_LOCK_SLOT=issue-17518`, verdicts read from the wrapper's own
`VERDICT command-exit` line and never a bare `$?`; every exit code
captured before any pipe. Wall-clock figures in the logs are SHARED-BOX
seconds.

- `pnpm --filter @objectstack/spec test` — **513 files / 14971 tests
passed, 1 todo** — the FULL suite, re-run on this head because the sixth
merge carried 128 commits of base movement including breaking spec
changes
- `pnpm --filter @objectstack/objectql test` — **303 files / 5057 tests
passed**
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`
over the package-door / artifact population, enumerated by a name match
on `packages/runtime` for `package` or `artifact` so the population is
reproducible — **39 files / 512 tests passed**. ⚠️ The first attempt
exited 1 in 2 seconds and is recorded as NOT a red: the paths were
repo-root-relative while `pnpm exec` runs at the package root, and the
repo's own guard said so in words (`FILTER SELECTED NOTHING — 39 of the
39 path(s) you named will run no tests`). Re-run with package-relative
paths for the reading above.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — exit 0; both test layers compile (spec **53 files / 257
errors / 142 pins**; objectql **40 / 234 / 65**, unchanged). ⚠️ The spec
ledger moved from 54 / 259 / 144 by main's objectstack-ai#19364 arriving in a merge, ⛔
not by this PR.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — both exit 0 on this head; the debt ledgers held shrink-only
(spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 /
65).
- `pnpm --filter @objectstack/spec build` exit 0 (34/34 declared `.d.ts`
present, `check-dts-references` resolved 378/378), and the whole
`@objectstack/runtime` dependency closure was rebuilt first, so nothing
below read a dist stale against 128 commits of main.

**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived from this tree, every command run
with its exit code written to a file, reconciled with `--ran`: **116
derived, 114 run, 2 NOT-MEASURED, 0 UNRUN**, and the tool's own verdict
line says so. **113 exit 0.** The two NOT-MEASURED are the tool's
DERIVED classification of an exit 3; a third measured nothing too, and
the tool cannot see it because its refusal code is 2. ⛔ None of the
three is a finding:

- `check:dual-build-cjs-loads` — exit **3**, its own `PREREQUISITE NOT
MET … ⛔ This is NOT a pass: nothing was measured` (66 packages have no
`dist`; it wants a whole-repo build).
- `check:type-check-debt` — exit **3**, same shape, same wording, wants
the full package closure built.
- `check-engine-split-ratio --days 90` — exit **2**, refuses on a
shallow clone whose oldest visible commit sits inside the 90-day window.
It says a ratio derived there would be 「real, plausible and WRONG」.

A fourth, `check:skill-examples`, first exited 1 on an unbuilt
`packages/client-react`; after building that package it re-runs
**green** — 258 prose examples type-check across 3 surfaces. Both
readings are stated here, and the reconciliation record carries ONE of
them — the green re-run — because the tool flags a doubly-recorded
family and says to make the record state one thing. The re-derivation on
the final head yields **116** families: `check:api-surface-declarations`
is gone (retired upstream by objectstack-ai#19024 mid-round) and
`check:gitlink-declared` is new, run green. No family is left unrun.

Ratchet families re-run after the last merge, on `96dd3549ff6`:
`check:generated` (all 15 artifacts up to date), `check:api-surface`,
`check:authorable-surface`, `check:export-origins`,
`check:declaration-map`, `check:docs`, `check:skill-refs`,
`check:entry-nameability`, `check:dual-source-exports`,
`check:spec-changes`, `check:spec-parsed-alias`,
`check:published-files`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage` — all exit 0. Control characters: `grep
-naP` over every file I hand-edited returns nothing (exit 1).

## Generated artefacts in this diff, and why each moved

- `json-schema.manifest/automation.json`,
`authorable-surface/automation.json`,
`authorable-defaults/automation.json`, `api-surface/*`,
`export-origins/*`, `declaration-map/automation.json`,
`content/docs/references/**` — the new exports, regenerated by the
package's own `gen:` scripts. `authorable-defaults` records
`automation/FlowFunctionLoweredDeclaration:effect = "pure"`, which is
the confidence-gap reading in ledger form.
- `packages/spec/dropped-refinements.baseline.json` — four `api/*`
entries each gain one site (`…manifest.hooks.element.object`), counts
569 → 573. Cause: the record stage **declares** `hooks` where
`z.unknown()` declared nothing, so `HookSchema`'s `object` refinement
now reaches the runtime and not the published file. The ledger is
hand-edited by design and the build printed the exact delta.
- `skills/objectstack-platform/references/_index.md` — one generated
line listing `stack.zod.ts`'s exports.

## `skills/**` readings, and the landing tier

This diff touches `skills/objectstack-platform/references/_index.md`, so
the PR is **governed, Tier H** on its file list. ⛔ It stays a draft and
no AI seat merges, queues or arms auto-merge on it.

Both readings the skills rule requires, at merge base `c334ba0f3a6`:

- **changed file, whole file**: 41 lines before, 41 after — net **0**.
The diff is one regenerated line.
- **package total (sum of every `SKILL.md`)**: 6145 before, 6145 after —
net **0**.

`node scripts/check-skills-token-ratchet.mjs` exits 0 and classifies
this file as **generator-owned (measured, not ratcheted)**, so no
authored ceiling is charged.

## Clause ②, and the changeset is not one package's

`Clause-②: yes`, and two changesets because two published packages move:

- `@objectstack/spec` — **minor**. New exports, and the two
installed-package responses move from `z.unknown()` on `functions` /
`hooks` to declared JSON shapes. That is a narrowing on a published
declaration; what it does NOT withdraw is measured, on real producers:
the showcase shape, the array form and the already-lowered body an
artifact boot installs all parse.
- `@objectstack/objectql` — **patch**. `GET /packages` reports functions
it previously dropped. No API is added or removed; a read door stops
under-reporting. Grade it up if a payload gaining entries reads as minor
to the reviewer.

## Serial and merge state, re-taken by this seat

Changed-file map re-taken first-hand over all **33** open PRs (271 file
rows) rather than inherited. LIT control
`packages/spec/src/ui/action-params.zod.ts` resolves to objectstack-ai#19315; DARK
control `packages/spec/src/zzz-no-such.zod.ts` resolves to nothing.

- `packages/spec/src/automation/flow-function.zod.ts`,
`packages/spec/src/api/package-api.zod.ts`,
`packages/objectql/src/registry.ts` — **free**.
- `packages/spec/src/stack.zod.ts` — held by objectstack-ai#18482, objectstack-ai#19147, objectstack-ai#19314, all
below A′'s region. objectstack-ai#19147 landed during this round and merged cleanly
here (its `stack.zod.ts` hunk is a comment).
- `packages/spec/dropped-refinements.baseline.json` — also written by
objectstack-ai#19147 (landed, resolved here) and by the still-open **objectstack-ai#19335**, which
rewrites the same `measured` header and adds entries. That is a
line-level contention on a ledger whose correct value is recomputable:
whoever lands second re-runs `pnpm --filter @objectstack/spec build` and
re-applies the delta it prints. ⛔ Not a semantic collision.

`origin/main` has been merged **six** times on this branch. `objectstack-ai#19024`
(which retired `api-surface-declarations/`) came in early, which is why
no `api-surface-declarations/*.txt` appears in this diff. The fifth
merge brought **objectstack-ai#19363**, a BREAKING spec change. The **sixth** merge,
the head of this body, brought **128 commits** — so the full spec suite
was re-run rather than only the generated gates.

⛔ `scripts/pm/os-regen-merge.sh` was NOT used in either round — its
`rerun` arm is re-entrant and commits a revert of the operator's own
regeneration, filed as **objectstack-ai#19392**. Steps 1–3 of its documented order
were performed by hand, against a merge base captured BEFORE the merge
and an `origin/main` fetched into an OWNED ref so a sibling's fetch
could not move the target mid-round.

**The sixth merge decided THREE paths, and only one of them was a
conflict.** That gap is worth stating, because resolving only what a
conflict probe names would have landed a silent loss:

| path | routed | what the merge did | how it was resolved |
|:--|:--|:--|:--|
| `content/docs/references/index.mdx` | `merge=os-regen` | driver
deferred it, exit 0 — **main's side silently dropped** (merged blob
`6290447bd9a` == ours, != theirs `7e1f9b6f13e`) | main's side restored
into the WORKING TREE ONLY, then regenerated whole |
| `content/docs/references/api/package-api.mdx` | `merge=os-regen` |
same — **main's side silently dropped** (merged `988bedaa480` == ours,
!= theirs `d09cd420711`) | same |
| `packages/spec/dropped-refinements.baseline.json` | **not** routed |
exit 1 — the only real text conflict, one hunk, confined to three
summary counters in the `measured` header | both sides' entries unioned,
then the build adjudicated |

⚠️ **`package-api.mdx` appears in NO conflict list and never could.** It
text-merges cleanly driver-free, so a GitHub-condition probe cannot name
it; only the both-edited ROUTED set, computed per file against the
pre-merge base, finds it — which is exactly what `os-regen-merge.sh`
step 2 specifies and what the driver's own `$GIT_DIR/os-regen-pending`
record listed.

**The regenerated docs are the UNION, proven in both directions**
(added/removed line multisets compared as sets): `package-api.mdx`
identical at 20 and 14 lines; `index.mdx` identical at 12 and 6 lines,
excluding the two running-total lines — a union MUST move a total
neither side moves alone, so their disagreement is the signature of a
correct union rather than a failure, and the line counts already matched
(16/16, 10/10) before excluding them. The total is **re-derived, not
arithmetic**: base 1533, this branch alone 1534, main alone 1534, merged
tree **1535**, and 1535 is what `gen:schema` itself reports for the
merged sources. Main brought `DatasetSelection`, `DatasetCompareTo` and
`DatasetTotals` and retired `KernelSecurityScanResult` /
`KernelSecurityVulnerability`; this branch brought
`FlowFunctionLoweredDeclaration`. All survive, asserted through the
published export map of the freshly built dist with a dark control (an
invented export name reads undefined).

**The ledger was resolved by hand, and that is the only route
available.** `dropped-refinements.baseline.json` is hand-edited BY
DESIGN with no `gen:` script — its own description states why: *"a
generator would let a new gap be admitted by running a command instead
of by a decision, which is the silence this ledger exists to end."* The
build VALIDATES it bidirectionally and refuses; it never writes it. Both
sides' entries were unioned (union keys missing from the merged file:
**none**; merged keys not in the union: **none**; `api/DatasetSelection`
arrived from main via objectstack-ai#19638 and survives; main's removal of the
`fields.out.keyType` sites is kept — **nine** site lines at the merge
base, zero at this head and zero on main (lit control: 204 `"sites"`
keys at base; dark control 0). ⚠️ The merge round's own prose said
*five*; that was a narrative miscount caught by the merge-delta review
and re-counted by the seat. The FILE was always right), then
`gen:schema` adjudicated and measured 565 dropped sites across 205
published schemas — the union as resolved. One counter the build
corrected: `refinementSitesThatDidProject` read 357 and the build
measures 366.

⚠️ **That correction is filed as objectstack-ai#19681**, because nothing in the
repository would have caught it: two of the four `measured` counters
have no reader anywhere (lit control — the other two have two readers
each, dark control 0), so they can hold any number and every gate stays
green.

## Acceptance notes

- **noted, not filed**: regenerating
`packages/spec/api-surface-declarations/ui.txt` produced a 184-line
change that is a pure permutation of its own content — the same union
members in a different order, `0 removed, 0 added, 35 reshaped`.
Verified as a precedented shape rather than a defect: commit
`24d622b94b8`, a spec change touching **zero** files under
`packages/spec/src/ui/`, moved the same file by 5 lines whose sorted
content is byte-identical. The whole artefact was retired upstream by
objectstack-ai#19024 mid-round, so nothing of it survives in this diff and the
population is gone. **Carrier: none — the file no longer exists.**
- **noted, not filed**: `packages/objectql`'s tests resolve
`@objectstack/metadata-protocol` from `dist`, so after merging upstream
objectstack-ai#19277 the seven assertions in
`protocol-install-package-enable-on-install.test.ts` failed against a
stale build of a package this PR never touches; building that one
package turns all seven green. A local-environment reading, not a repo
defect, and `check:test-source-alias` already owns the aliased/unaliased
ledger this sits in. **Carrier: the next seat that runs objectql's suite
after a merge — it will see the same red and should build the dependency
before reading it as a finding.**

## 维护者速读(草稿)

**改了什么** —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact
的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:`ArtifactStagePackageBodySchema`(落盘
artifact)和
`RecordStagePackageBodySchema`(注册表记录),放在既有的装配体**旁边**,装配体一个字不动。同时修好一个生产者缺陷:`GET
/packages` 以前会把「裸写的函数」整条漏报,现在两种写法都报。

**为什么改** —— 两件事各有代价。其一,装配体里有两个键(`functions`、`hooks`)声明了「可以是一个活的函数」,而
JSON Schema 表达不了函数,于是**任何嵌入它的接口都会整份丢掉自己的 JSON Schema**;读 API
只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 `GET /packages` 只报 1
个——机器可读的读门把事实说少了。

**风险与代价(含回滚)** ——
风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase
的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与
`composeStacks` 未动,所以 `os dev` / `os serve` 的行为不受影响——这正是上一版裁决 B
被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,`git revert`
任一半都不会让另一半变红;最小回滚是把 `package-api.zod.ts` 的那一行绑回装配体,新声明留着不用。

**席位意见** ——

**你要做的** —— 这个 PR 的文件里有一份 `skills/**` 的生成文件,按规则整单属于 Tier
H,**只有你(或你授权的批准)能让它落地**;AI 席位不会合并、不会排队、不会解除 draft。请看两点:①
`@objectstack/objectql` 我打的是 `patch`,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算
minor,说一声即可改;② `functions` 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1
条),如果裁决本意是只收记录式那一种,也请直接说,那会让 `objectstack build` 今天写出的一种 artifact 被拒。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

3 participants