fix(spec)!: a dataset-bound dashboard widget owns appearance, the dataset owns structure - #19363
Conversation
…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>
…artconfig-precedence-half-2
…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>
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 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
|
Contract reviewServed-tier: Isolated reviewer, ① Derived judgments1. The Clause-② move is correct, and the widening is entailed, not chosen.
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 2. The inline-data face is unchanged, and the premise correction is right. 3. 4. The capability loss is recorded in all four places, and in the place a consumer reads. Read at head: (a) 5. Both door verdicts moved as stated, and the amended assertion is still falsifiable. 6. The ADR-0087 kit matches #17835's shape and nothing was lost across the merges. Shape: a 7. CI on ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… 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>
…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>
…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>
…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>
Fixes #17385
Clause-②: yes (narrowing)
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 theyesvalue with thenarrowingarm (the spellingcheck-adr-0087-registration.mjspins for "a diff that widens AND narrows"). ⛔ Nothing here touches the card's declaration or theneeds:contract-reviewlabel; 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.datasetis REQUIRED, so every dashboard widget is dataset-bound, and ADR-0021 already made the dataset the owner of the chart's structure.chartConfignonetheless declaredtype/xAxis/yAxis/series, with no rule between the two answers.That was not merely inert. An authored
yAxis[].fieldwas 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.ChartConfigSchemaand on the widget'schartConfig, citing ADR-0021: the dataset decides which series exist and which column each one reads;chartConfigcarries appearance.DashboardWidgetChartConfigSchema—ChartConfigSchema.extend(…), theReportChartSchemaspelling — which tombstones the four keys withretiredKey(). Each refusal names the dataset selection the intent belongs in.page.assignedProfilesand answerprofiles:with the permission-set route #17835's shape: the D2 conversiondashboard-widget-chart-config-structure-removed(dashboards only), the D3 semantic entrydashboard-widget-chart-config-structure-refusedcarrying the structured TODO, fourRETIRED_KEYS_BY_MAJOR[18]entries, all registered throughgen:migration-registry.minorchangeset with the**BREAKING**banner and a FROM → TO table.⭐ The accept-set probe, before and after, on both faces
Run against the built
distat each state, exit codes captured before any pipe.chartConfig.typechartConfig.typechartConfig.xAxischartConfig.xAxischartConfig.yAxischartConfig.yAxischartConfig.serieschartConfig.seriestypetypetombstone)typetypewas required)chartConfigat alltypetype+xAxistype+yAxistype+seriestype+xAxis/yAxisas dataset namesseriesThe inline-data arm is unchanged in every row, which is the half ruling item 1 requires and the half a tombstone on the shared
ChartConfigSchemawould have silently broken.DashboardWidgetSchema.datasetis required, so a dashboard widget is dataset-bound by construction. The inline-data face is the react tier'sObjectChartwith adatabinding, whose contract ISChartConfigSchema(react-blocks.tssetsschema: ChartConfigSchemaand publishes all four keys in itsdataProps) — 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.typeis REQUIRED. Before this change achartConfigwith notypewas refused as incomplete — on every face. Ruling item 2 refusestypeon the widget; ruling item 1 sayschartConfigstill carries appearance there. Both can hold only if absence becomes legal on that carrier, whichretiredKey()(az.never().optional()) does. So exactly one document class moves from refused to accepted:chartConfigwith notypeon a dashboard widget.⛔ The alternative is not a narrower reading, it is a contradiction: leaving
typerequired while refusing every value makeschartConfigUNAUTHORABLE 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.xAxistombstone from the widget carrier. Anchor occurrences 1 → 0, injected marker 0 → 1, blob919d93f07fa8→1f57132a1ace.vitest run src/ui/dashboard-chart-structure-refusal.test.tsexit 1 — 9 failed / 6 passed.git checkout HEAD -- PATH, never a bare checkout, under a trap with absolute paths): blob back to919d93f07fa8,git diff HEADempty, exit 0 — 15 passed.No
distleg: the pin imports./dashboard.zodfrom source inside its own package, so there is noexportsresolution 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:ChartConfigSchema→derived-clone. No root reaches the base shape itself; both carriers are.extend()clones that carry its strictness and error map. Still a door, in the testkit's own vocabulary.ChartAxisSchema→unreachable. A change of fact, not of route: the widget clone tombstonesxAxis/yAxisandReportChartSchemare-declares both as dataset-name strings, so no authoring path from a metadata root parses an axis OBJECT any more. Its remaining carrier is the react tier's publisheddataProps, which is a declaration, not a parse — so the datasource 账本判定的 20 条死键至今无人处置:三个块整块无人读,其中 readOnly 让一个 shipped 示例的「只读副本」可写(ADR-0049 enforce-or-remove) #4583 question is genuinely open for that one shape. Recorded, not answered here.ChartSeriesSchema/ChartAnnotationSchema/ChartInteractionSchemastaydirect, 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[].typehas 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'scombo_count_vs_progresswidget records the loss at its site.The changeset level
minor, not themajorruling item 3 asks for, and the authority was re-read on this branch's own base rather than inherited:docs/adr/0087-metadata-protocol-upgrade-contract.md, 「Amended 2026-09-13 ([Decision] 两条裁决援引同一个 launch-window convention,却给出相反的 changeset 等级(minorvsmajor)—— 退役一个可写键到底发哪一级? #18003) — the level half」: "Pre-GA, a metadata-facing retirement or break shipsminor", carrying the**BREAKING**banner and its ADR-0087 disposition; "An npmmajoris a planned act … never a side effect of one retirement card". Present verbatim ate3b3cdd2df3.completed2026-09-14T00:36:28Z with the director class-one adjudication naming that amendment: "Ruling ② (List viewnavigation.viewis declared in spec but resolves no form view — its only read lands it in theonNavigatenavigation-MODE argument #16885 item 5,major) is superseded by that text; ruling ① ([finding] PageSchema.assignedProfiles is an authorable key named for the concept ADR-0090 D2 removed, and the alias map corrects an authoredprofiles:into it #16929,minor) stands."profiles:into it #16929 closedcompleted2026-09-14T00:03:49Z, landed by PR fix(spec): retirepage.assignedProfilesand answerprofiles:with the permission-set route #17835 (merged 00:03:48Z), whose changeset for the identical retirement class reads'@objectstack/spec': minor.packages/spec/package.jsonis17.4.0andPROTOCOL_VERSIONis'17.0.0'— the migration entries are numbered at protocol 18 while the package stays on the 17.x line, which is the amendment working as written. The tombstones therefore name the npm release17.5.0, never the protocol major (the same amendment's third bullet).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-configover the WHOLE repo — 6932 files judged, 0 messages, exit 0. Not a narrowed run, so no narrowing claim is owed.dispatch-gates.mjs --commandson 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-levelentered 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/mainwas merged throughscripts/pm/os-regen-merge.sh(merge committed BEFORE regeneration) and the registries were diffed againstorigin/mainafterwards: 0 entry ids lost, exactly the 2 this PR adds.skills/**budgetThis 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; everySKILL.mdinskills/summed, 6145 → 6145; the wholeskills/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'schart-field-unknownstill 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 onvalidate-widget-bindings.ts, whose rule inventory this belongs to.noted, not filed:.claude/skills/spec-property-retirement/SKILL.md§4 still instructs@objectstack/spec用majorfor 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=2forwards the flag into each package's own script;packages/cli's shell-ifbuild script then dies withsh: 1: Syntax error: word unexpected. Hit once in this round and worked around with--workspace-concurrency=2placed 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