Skip to content

Fix fumadocs-mdx validation: flatten nested pages in concepts meta.json - #210

Merged
hotlong merged 2 commits into
mainfrom
copilot/update-specification-references
Jan 26, 2026
Merged

hotlong merged 2 commits into
mainfrom
copilot/update-specification-references

Conversation

Copilot AI commented Jan 26, 2026 •

Copy link
Copy Markdown
Contributor

CI build failing on meta.json validation - fumadocs-mdx expects flat string arrays in pages, not nested objects.

Changes

  • Flattened pages array in /content/docs/concepts/meta.json and meta.cn.json
  • Removed nested "Protocol Namespaces" grouping object at pages[3]
  • All protocol-* entries now at top level

Before:

{
  "pages": [
    "manifesto",
    "architecture",
    {
      "title": "Protocol Namespaces",
      "pages": ["protocol-data", "protocol-driver", ...]
    },
    "terminology"
  ]
}

After:

{
  "pages": [
    "manifesto",
    "architecture",
    "protocol-data",
    "protocol-driver",
    ...,
    "terminology"
  ]
}

Sidebar navigation will show protocol pages in sequence rather than nested under a group heading.

Original prompt

引用: https://github.com/objectstack-ai/spec/actions/runs/21345690146/job/61432691384#step:8:1


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel

vercel Bot commented Jan 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
spec Error Error Jan 26, 2026 4:00am

Request Review

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Update specification references in project Fix fumadocs-mdx validation: flatten nested pages in concepts meta.json Jan 26, 2026
Copilot AI requested a review from hotlong January 26, 2026 04:00
@hotlong
hotlong marked this pull request as ready for review January 26, 2026 04:32
Copilot AI review requested due to automatic review settings January 26, 2026 04:32
@hotlong
hotlong merged commit 45c265f into main Jan 26, 2026
4 of 5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a CI build failure by updating the fumadocs-mdx metadata structure in the concepts documentation. The nested "Protocol Namespaces" grouping structure was causing validation errors because fumadocs-mdx expects flat string arrays in the pages field, not nested objects.

Changes:

  • Flattened the pages array structure in both English and Chinese concept meta files
  • Removed the nested "Protocol Namespaces" grouping object
  • Maintained all 11 protocol pages at the top level in the same sequential order

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
content/docs/concepts/meta.json Flattened pages array from nested structure to flat list of protocol page references
content/docs/concepts/meta.cn.json Flattened pages array from nested structure to flat list (Chinese version)

This was referenced Sep 22, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… the parse (objectstack-ai#19690)

Fixes objectstack-ai#19273

Clause-②: yes

Ruling batch objectstack-ai#210 item 4 · letter A · maintainer 「210 同意」 (`5770455384`,
2026-09-22T02:41Z). The direction was ruled, not chosen here.

## The defect

`packages/runtime/src/domains/packages.ts:1095` has honoured 「缺省 = 保持,有旗
= 设置」 since PR objectstack-ai#19291 landed:

```
const requestedEnabled = wrapped ? body?.enableOnInstall : undefined;
```

`true` calls `enablePackage`, `false` calls `disablePackage`, and an
**absent** key makes no lifecycle call at all — so a package an operator
disabled stays disabled across an upgrade. Verified unchanged on this
branch; the runtime is not touched by this PR.

The published declarations said something else.
`z.boolean().default(true)` resolves absence **at parse time**, so a
request that omitted the key came out of the parse byte-identical to one
that set `true`. The third state did not exist on the published surface
while the door went on acting on it — a declared default the runtime
deliberately stops applying, on a contract this repo does not own both
ends of.

## What changed

All three declarations now spell `z.boolean().optional()`, with the
semantics on the field in **both** the `describe` and the docblock —
absent = keep the row's current lifecycle state; explicit `true` /
`false` unchanged; a fresh install lands enabled:

| declaration | file |
| :--- | :--- |
| `api/PackageInstallRequest` — the authority |
`packages/spec/src/api/package-api.zod.ts` |
| `kernel/InstallPackageRequest` — the copy |
`packages/spec/src/kernel/package-registry.zod.ts` |
| `marketplace/MarketplaceInstallRequest` — a different party's key |
`packages/spec/src/marketplace/marketplace.zod.ts` |

### The executable criterion, both directions

Read off the **built** package (`packages/spec/dist`), not `src/`, at
head `f5b094a96`:

```
api/PackageInstallRequest    | absent => undefined (key in parse output: false) | true => true | false => false
kernel|api/InstallPackageReq | absent => undefined (key in parse output: false) | true => true | false => false
marketplace/MarketplaceInst. | absent => undefined (key in parse output: false) | true => true | false => false
```

The `true` and `false` arms are **re-read after the change on all three
declarations, never assumed** — the card's control in the other
direction: a fix that makes absence visible by making the key mean
nothing would be worse than the defect. The two refusal cells are
unmoved: a string `'false'` and `null` are still refused by name.

### PR objectstack-ai#19130's consistency pin — flipped with its trigger registered, ⛔
not patched green

`packages/spec/src/api/package-install-one-authority.test.ts` asserted
`true` on every 缺省 reading. Only the 缺省 cell moves; the `false`, `true`,
string and `null` cells are untouched, and the authority/copy agreement
is still judged cell by cell.

The **flip-trigger phrase registered in the test** is:

```
缺省 = 保持,有旗 = 设置
```

It is a named `FLIP_TRIGGER` const with its own docblock explaining that
while the declarations spelled `.default(true)` the 缺省 reading was
living on borrowed time — the phrase says absence is a state the door
ACTS ON, and a `.default()` resolves absence at parse time so that state
cannot survive to the published surface. It is quoted into the 缺省 cell's
name so a test run prints it, and into the two flipped assertion titles.
The file's header docblock carries a section stating that the cell
FLIPPED, that this was expected on the day the pin landed, and that
reading the red as "the pin needs updating" and writing the new value in
silently is the failure the const exists to prevent.

## ⚠️ DECLARED file-surface expansion, with the mechanism that forces it

Beyond the three declarations, their tests and the changeset, four more
paths are in this diff. Each is mechanically forced; none is a
discretionary edit.

1. **`packages/spec/scripts/lib/default-changes.ts`** (+101).
`check:authorable-surface` **refuses the build** on an undeclared move
of an authorable key's default, and prints the copy-pasteable block
naming each key and both fingerprints. The build exits 1 until the
entries exist. Four entries are required, not three:
`InstallPackageRequestSchema` is re-exported through
`src/api/protocol.zod.ts`, so one declaration publishes under **two**
def keys (`kernel/InstallPackageRequest` and
`api/InstallPackageRequest`, byte-identical but for the `$id`) — the
`CreateImportJobRequest` / `ImportRequest` shape already in that table.
The ratchet names keys, not schemas, so dropping either row leaves that
def unauthorised and the gate red.
2. **`packages/spec/authorable-defaults/{api,kernel,marketplace}.json`**
(-4 lines total). Generated. `pnpm --filter @objectstack/spec build`
writes them; exactly the four `… = true` entries are removed and nothing
else moves.
3.
**`content/docs/references/{api/package-api,api/protocol,kernel/package-registry,marketplace/marketplace}.mdx`**
(+5 / -5). Generated by `gen:docs`, run via `check:generated --fix`,
which regenerated **only** the one artefact it proved stale. The four
projected rows lose their `(default: true)` cell and gain the
three-state prose. No other row moves.

`authorable-surface/*.json` and `authorable-surface.base.json` are
**not** in this diff: the keys stay authorable, and the base anchor is
only ever written by the explicit `gen:authorable-surface-base`, never
by a build.

## Verification

Reconciliation line, verbatim, derived and run at head `f5b094a96`:

```
Run reconciliation — 108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN.
```

`✓ dispatch-gates --ran: 108 derived famil(ies) accounted for — 108 run,
0 NOT-MEASURED (a DERIVED zero — all 108 recorded an exit code and none
of them is 3).` Every command's exit code was captured **before any
pipe**; no command answered `exit 3`, so nothing in the derived set
measured nothing.

Everything below ran in the foreground; each heavy run went through
`scripts/pm/os-verify-lock.sh` with `OS_VERIFY_LOCK_SLOT=issue-19273`,
and each verdict is that wrapper's own `VERDICT command-exit` line,
never a bare shell status.

| run | verdict |
| :--- | :--- |
| `pnpm --filter @objectstack/spec test` | `VERDICT command-exit 0` —
512 files, 14955 passed, 1 todo |
| `pnpm --filter @objectstack/rest test` | `VERDICT command-exit 0` —
194 files, 3265 passed, 1 skipped |
| `pnpm --filter @objectstack/runtime test` | `VERDICT command-exit 0` —
272 files, 3799 passed, 1 skipped |
| `pnpm exec turbo run typecheck` | `VERDICT command-exit 0` — 143 tasks
successful |
| `pnpm build` | `VERDICT command-exit 0` — 73 tasks successful |
| `pnpm --filter @objectstack/spec check:generated` | `VERDICT
command-exit 0` — all 15 generated artifacts up to date |
| `pnpm lint` | **exit 0**, run WHOLE (`eslint . --no-inline-config`),
not narrowed — so no narrowing evidence is owed |

`origin/main` was merged and the build state refreshed before the final
push; the generated re-check and the union above were both taken
**after** that merge, on the head this PR carries.

## ⚠️ The open reading the ruling hands the dev, reported as a zero WITH
its radius

**Zero consumers found that parse an install request through the
published schema.** The instrument's reachable radius, stated because a
zero without one is not a reading:

- **Reached:** `objectstack-ai/objectstack` at `f5b094a96` —
`packages/**`, `apps/**`, `examples/**`, `scripts/**`, `content/**`,
`docs/**`, `skills/**`, excluding `node_modules`. And
`objectstack-ai/objectui` at `0cf2d66`, the only sibling checkout in
this container, excluding `node_modules`.
- **objectui reading, with a positive control:** `enableOnInstall` —
**0** hits. `PackageInstall` (the schema name) — **0** hits. Control
that proves the instrument reads that tree: `packages.install` /
`/api/v1/packages` — **52** hits. So objectui calls the install route
and never names the key, never parses through the published schema.
- **⛔ NOT reached, and so NOT established in either direction:**
`objectstack-ai/cloud` (no checkout exists in this container) and any
third-party consumer of the published `@objectstack/spec`. The changeset
body and all four `DEFAULT_CHANGES_BY_MAJOR` reasons are written for
exactly that unreachable consumer — the caller who validates before
sending — because they are the only channel that reaches them.

## Changeset grade

**`minor`** for `@objectstack/spec`, ⛔ not the `patch` ruling objectstack-ai#157 item
5 wrote. Ruling objectstack-ai#210 item 4 overrode it and the override is measured:
`check-changeset-no-major.mjs`'s `judgeLevel` verdict `enforce` refuses
a clause-②-carrying diff whose moved packages are graded `patch` with
none at `minor` or above. Judged against `packages/spec/package.json`'s
`files[]` after a build as usual — `dist/` and `json-schema/` both ship,
and both move here — so the floor and the measurement agree. `node
scripts/check-changeset-no-major.mjs --base origin/main` and `node
scripts/check-adr-0087-registration.mjs --base origin/main` both exit 0
on this head.

## ⛔ Fences honoured

- **Not the engine half.**
`packages/runtime/src/domains/packages.ts:1095` verified to still read
`const requestedEnabled = wrapped ? body?.enableOnInstall : undefined;`.
The runtime is not in this diff.
- **The door does not sniff the raw body around the schema.** Nothing in
this PR adds a parse on the serving path.
- **No label writes of any kind**, and **no new issues filed** —
findings go back to the dispatching seat.

## Acceptance notes

None. Nothing outside this card's scope was surfaced that meets the
filing bar.

## 维护者速读(草稿)

**改了什么** — 三处 `enableOnInstall` 声明从「默认
true」改成「可缺省」。安装接口的实际行为半年前就被裁决改成了「不写这个键 =
保持这个包当前的启用/停用状态」,但对外发布的协议声明一直还写着「不写 = 启用」。这次让声明跟上已经生效的行为。

**为什么改** — 声明与实际不一致,受伤的是仓库外面的调用方。一个会先按协议校验请求再发送的客户端,会从「默认 true」里自动补出一个
`enableOnInstall: true` 发过来;而这个显式的 true
的含义是「强制启用」。结果就是:同样一个请求体,先校验的那一方会在每次升级时把运维手动停用的包悄悄重新打开,不校验的那一方则正常保持停用。两边行为相反,差别只在于有没有先校验。

**风险与代价(含回滚)** — 本仓内运行时行为零变化:安装接口读的是原始请求体,没有任何服务路径经过这几个 schema
解析,接受集也一个字节没动(缺省、true、false 照收,字符串和 null 照拒)。真正受影响的是仓外那位会校验的调用方,处方已写进
changeset 和四条默认值台账记录里:想要每次都强制启用,就把 `enableOnInstall: true`
显式写出来。回滚代价低——三处声明改回 `.default(true)`、撤掉四条台账记录、重跑生成即可,但回滚会把「声明 ≠
实际」这个问题原样退回去。

**席位意见** —

**你要做的** — 确认一件事就够了:仓外(尤其 cloud 侧和第三方)有没有会先按发布的 schema
校验安装请求、再把校验后的对象发出去的调用方。本次探测半径只到本仓和 objectui 两棵树,读数为零且带正控(objectui
会调安装接口但从不提这个键);cloud
在本容器里没有检出,所以那边是**未测**,不是「没有」。若那边确实有这样的调用方,它就是这次改动唯一会碰到的对象,而 changeset
里的处方正是写给它的。

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nencrypted, in English (objectstack-ai#19689)

Fixes objectstack-ai#19571

Implements ruling batch objectstack-ai#210 item 5 (letter B/B, maintainer 「210 同意」),
which governs over the issue body. Upstream ruling: objectstack-ai#19489 item ④. PR
objectstack-ai#19534 is landed and is **not** reverted here — this is its follow-up.

Clause-②: no

## D1 — eligibility decides WHICH sentence, never WHETHER one is emitted


`packages/plugins/plugin-auth/src/auth-plugin.ts#registerOidcDiscoveryRoutes`.

The structural defect, verified on `origin/main` before writing code:
the three AS discovery routes
(`/.well-known/oauth-authorization-server`,
`/.well-known/openid-configuration` and the RFC 8414 §3.1 path-insertion
variant) are mounted unconditionally, while the only line that mentioned
a refused transport — `MCP server is enabled but the OAuth track is NOT
live` — sits **inside** `if (readMcpServerEnabledEnv() && ...)`. A
public plain-HTTP boot with `OS_MCP_SERVER_ENABLED=false` therefore
emitted **no warning at all**, while publishing its authorization-server
metadata in the clear. The loudest-needed configuration was the
quietest.

The plain-HTTP branch now has a sibling `else if`, beside it and **not**
inside the MCP block:

- accepted origin (loopback / private / link-local) — unchanged line,
minus its CJK prefix: `OAuth is served UNENCRYPTED: ...`
- refused origin (public host) — new, distinct line: `OAuth discovery is
served over PUBLIC plain HTTP: ...`, naming (a) the discovery documents
and the issuer, (b) that the MCP OAuth track is DISABLED, (c) that TLS
is the remedy.

Both fire once at mount, neither under TLS, and no configuration key or
environment variable gates either.

**⛔ No admission decision changes.** `isOAuthEligibleBaseUrl` and every
transport-rule predicate are byte-unchanged; the discovery routes are
mounted exactly where and when they were. ⛔ No new exported symbol
(`servedOverPlainHttp` is a local const) — the `Clause-②: no`
declaration stands, and the mechanical floor at
`references/contract-review.md:12` is not tripped.

## D2 — the emitted string is English; the ruled sentence moves to the
comment

The `'OAuth 未加密:仅限可信内网 — '` prefix is out of the executable string. The
maintainer's sentence is kept verbatim in the code comment beside the
call, cited to the objectstack-ai#19489 ruling and to batch objectstack-ai#210 item 5, and read as
the line's meaning rather than its literal encoding. ⛔ No CJK executable
string remains in either file's `src` tree; ⛔ no bilingual line.
`AGENTS.md` is untouched.

The comment block above the branch previously argued the **opposite** —
that the Chinese belongs in the emitted string "not only in this
comment". That paragraph is falsified by this ruling and has been
rewritten to state the current rule with its citation.

## Carriers moved with it

1. `packages/plugins/plugin-auth/src/auth-plugin.ts` — the two branches
and the comment.
2. `packages/plugins/plugin-auth/src/mcp-oauth-plaintext-notice.test.ts`
— `NOTICE_MARKER` re-anchored to the English line;
`PUBLIC_NOTICE_MARKER` added; the "does NOT fire on a PUBLIC plain-HTTP
deployment" leg rewritten to assert the sentence **swaps** rather than
vanishes; new coverage for the MCP-surface-OFF boot, MCP-independence,
once-per-mount, warn level, mutual exclusivity and the no-env-var floor.
15 tests, all green.
3. `docs/qa/platform-checklist/areas/ai.json` —
`ai.mcp-oauth-private-host-transport` revision 1 → 2 with its history
entry. The grep anchors move to the two English lines; case (b) moves
from asserting **0** occurrences to asserting **exactly 1** of the new
line; step text, `clause`, `verify`, the `negative` conflation entry,
the `source` line and the title moved together.
4. `.changeset/19489-oauth-private-host-transport-rule.md` — still
unreleased on `origin/main` (the file is present in `.changeset/`, so
`changeset version` has not consumed it). Its last bullet asserted the
startup line carries the Chinese and that a public plain-HTTP boot gets
no such line; both become false when this lands, so the sentence is
corrected. The Chinese is kept only as a quoted ruling citation.

Plus a new changeset,
`.changeset/19571-plaintext-oauth-public-host-notice.md`
(`@objectstack/plugin-auth: patch`).

## ⚠️ One gate is red BY DESIGN and needs a human word

`node scripts/check-empty-changeset.mjs --base origin/main` — **exit
1**, on carrier 4:

> `.changeset/19489-oauth-private-host-transport-rule.md` present on the
merge base and CHANGED by this PR

This is the gate's **DELIBERATE CORRECTION** class, not the COLLISION
class. Its own text says the remedy is *not* to restore the file —
restoring it from the base republishes a sentence this PR makes false —
and that correcting a pending release note "is a decision about a
release rather than a refactor — say so on the PR, naming the note and
what changed under it, and get it confirmed". Naming it here: the note
is `19489-oauth-private-host-transport-rule.md`, and what changed under
it is the D1 branch above. The gate stays red until a person confirms.

## Verification

| check | result |
|:---|:---|
| `pnpm --filter @objectstack/plugin-auth test` | 114 files / **2439
passed** |
| `pnpm --filter @objectstack/plugin-auth run typecheck` | exit 0 (after
building the package; `tsconfig.examples.json` resolves through `dist`)
|
| `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 |
| `pnpm --filter '@objectstack/plugin-auth^...' build` | exit 0 |
| derived gate families (`scripts/pm/dispatch-gates.mjs --commands`), 66
commands | 63 green · 1 red by design (above) · 2 `PREREQUISITE NOT MET`
|

`pnpm check:platform-checklist`, `check:nul-bytes`,
`check:doc-authoring`, `check:test-source-alias`,
`check:cross-package-test-inputs`, `check:published-files`,
`check:type-check-coverage`, `check:adr-0087-registration`,
`check:changeset-no-major` all exit 0.

NOT MEASURED (each prints `PREREQUISITE NOT MET` — explicitly "not a
pass and not a finding" — and needs a full workspace build, which is
CI's): `pnpm check:dual-build-cjs-loads` (exit 3), `pnpm
check:type-check-debt` (exit 3). This diff changes no `exports`, no
`package.json` and no build shape.

### Reverse verification (both legs from the committed state; restored
byte-identically)

Run through `scripts/ablation-replace.mjs`, which proves the mutation
landed on disk by blob hash and proves the restore by `git diff HEAD`
being empty. The subject resolves from `src` through a relative import,
so no `dist` leg applies.

1. **The new branch can fail.** `} else if (servedOverPlainHttp) {` → `}
else if (servedOverPlainHttp && false) {`. Blob `17bd405104b2` →
`eb7d11ae9d01`. Result: **9 failed | 6 passed (15)** — every public-face
assertion red. Restored: blob back to `17bd405104b2`, `git diff HEAD` 0
bytes.
2. **The D2 pin can fail.** The CJK prefix re-added to the accepted
line. Blob `17bd405104b2` → `c7aba397fff2`. Result: **1 failed | 14
passed (15)** — `fires on a private-address deployment` red on the
no-CJK assertion. Restored: `ok restored: blob == HEAD (17bd405)
and git diff HEAD is empty`.

Predicted direction was RED for both, and RED is what was observed.

## Acceptance notes

- The comment now states explicitly that whether the `.well-known`
discovery routes should be mounted at all on a refused origin is
pre-existing `main` behaviour neither ruling touched, and is not decided
at that call site — per the ruling, that would be a card on its own
evidence, ⛔ not folded here.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…upgrade / resolve-dependencies / upload) (objectstack-ai#19937)

Fixes objectstack-ai#19116

Clause-②: no

Executes ruling 5793374037 on objectstack-ai#19116 (batch objectstack-ai#217 item 4, letter A,
maintainer 「217 同意」): the three `PackageApiContracts` entries that named
paths nothing mounts leave the contract map.

## What changed

- **`packages/spec/src/api/package-api.zod.ts`**
- `PackageApiContracts` loses `upgradePackage` (`POST
/api/v1/packages/upgrade`), `resolveDependencies` (`POST
/api/v1/packages/resolve-dependencies`) and `uploadArtifact` (`POST
/api/v1/packages/upload`). A comment at the removal site records why,
and carries ruling item 3: a package upgrade / dependency-resolution /
upload entry is declared only in the same change that mounts its route.
- The file-header `Endpoints` list loses the same three lines (this is
the text the generated reference page prints).
- Docblock-only corrections under the dispatch's Zone 2 exception, no
shape change, no `.describe()` touched:
1. section header `// 5. Upgrade Package (POST
/api/v1/packages/upgrade)` now reads `(request/response shapes — bound
to no route, see §11)`;
2. `PackageUpgradeRequestSchema` JSDoc: the `@example POST
/api/v1/packages/upgrade` request line is dropped (the example body
stays) and one sentence says no route accepts it;
    3. section header 6 (`resolve-dependencies`), same edit as 1;
    4. `ResolveDependenciesRequestSchema` JSDoc, same edit as 2;
    5. section header 7 (`upload`), same edit as 1;
6. `UploadArtifactRequestSchema` JSDoc, same edit as 2, and the
`Content-Type: multipart/form-data` line goes with the request line,
because it described the HTTP request of the unmounted route.
- **`packages/spec/src/api/package-api.test.ts`**: the three
`toBeDefined` / method / path pins on the removed keys are flipped into
a new block that asserts the new fact: none of the three keys is
present; no entry under any key is bound to any of the three paths (the
whole map is swept, so a phantom cannot return under another key); the
map holds exactly the four serving entries; and the six per-route
schemas still resolve from the `api` namespace (scope of the ruling, see
Acceptance notes).
- **ADR-0087**: D3 semantic entry
`package-api-contracts-unmounted-entries-retired`
(`packages/spec/src/migrations/entries/semantic/18.package-api-contracts-unmounted-entries-retired.ts`),
concatenated into `registry.ts` by `gen:migration-registry`. A
contract-map entry is not metadata, so there is no source a D2
conversion could rewrite.
- **Generated**: `content/docs/references/api/package-api.mdx`
regenerated by `check:generated --fix` (only `check:docs` was proven
stale): the three lines are gone. ⛔ Not hand-edited. `spec-changes.json`
/ `docs/protocol-upgrade-guide.md` were NOT stale (step-18 semantic
entries are not projected yet), measured by `check:spec-changes` /
`check:upgrade-guide` green.
- **Changeset**
`.changeset/19116-package-api-unmounted-entries-retired.md`:
`@objectstack/spec` **minor** with a **BREAKING** banner, a FROM → TO
table and the one-line fix, and the ADR-0087 disposition `registered
package-api-contracts-unmounted-entries-retired`. Level chosen by the
launch-window convention: `scripts/check-changeset-no-major.mjs` refuses
`major` outside pre-mode (`.changeset/pre.json` absent), and ruling
5770445652 on objectstack-ai#19611 (batch objectstack-ai#210 item 3) ratified `minor` + BREAKING
banner + ADR-0087 marker as the carrier for a breaking spec retirement.
Rule text read: AGENTS.md Post-Task Checklist step 3 (breaking
changesets carry FROM → TO + one-line fix; exactly one ADR-0087 marker;
the changeset carries the PR's `Clause-②` line).

Runtime behaviour is unchanged: nothing mounted the three paths or built
a route from the entries.

## Premise re-taken at `fdeeea0cc9` (this branch's base)

- The three path strings occur in exactly 3 files (`package-api.zod.ts`,
`package-api.test.ts`, `content/docs/references/api/package-api.mdx`),
`.map` and `CHANGELOG.md` excluded; control `/api/v1/packages/publish`:
27 files.
- `upgradePackage` occurs only in the two spec files.
`resolveDependencies` / `uploadArtifact` also occur in `packages/core`
kernels and `packages/spec/src/contracts/package-service.ts`: those are
the kernel's plugin sort and the `IPackageService` methods, a different
surface, not readers of the map.
- No reader of `PackageApiContracts` outside `package-api.test.ts`
(non-generated, non-changeset); no route table, client method,
SDK/codegen, MCP tool or doc generator iterates it.
`handlePackagesRequest` (`packages/runtime/src/domains/packages.ts`) has
no single-segment `POST` branch, so the three paths fall through (static
read; the dynamic `handled=false` reading is the one recorded on
objectstack-ai#18604).
- objectui at the pin `62597c58` (the sibling checkout's HEAD equals
`.objectui-sha`): 0 files for `PackageApiContracts`, each of the three
keys, each of the three paths; controls
`GetMetaItemLayeredResponseSchema` 14 files, `/api/v1/packages` 33
files.

## Verification (all at head `3c4da3412`)

- `pnpm --filter @objectstack/spec build` (under the verify lock) → exit
0.
- `pnpm --filter @objectstack/spec test` → 527 files, 15507 passed / 1
todo, exit 0. `test:repo` → 35 files, 602 passed, exit 0. `typecheck`
(tsc + scripts + test-typecheck) → exit 0.
- Cross-package suites whose turbo inputs cover the diff:
`@objectstack/core` / `types` / `runtime` / `objectql` / `rest`
`test:repo` → 3/1/2/1/1 files, all passed (runtime and objectql after
building their dependency closure; the first runtime attempt refused on
unbuilt dependencies and is not counted).
- `@objectstack/downstream-contract` `test` → 2 files passed, 1 file NOT
MEASURED: `consumer-specifier-ledger.test.ts` refuses because
`@objectstack/cli` is not built; it measures published export
specifiers, which this diff does not move.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` → 111 commands, each run with its exit code
recorded; `--ran` reconciliation: **111 derived, 109 run, 2 NOT
MEASURED, 0 UNRUN**.
- NOT MEASURED, reason PREREQUISITE NOT MET (exit 3, a whole-tree
build): `pnpm check:dual-build-cjs-loads`, `pnpm check:type-check-debt`.
Both are in CI's required jobs.
- Three families first exited 3 (`@objectstack/lint`
`check:doc-formula-expressions` / `check:doc-security-posture`,
`@objectstack/spec` `check:skill-examples`); after building their named
prerequisites (`turbo run build` for formula, lint, client-react, 34
tasks) all three exited 0.
- `check:adr-0087-registration`: `1 declared-breaking changeset(s), each
carrying an ADR-0087 disposition … registered
package-api-contracts-unmounted-entries-retired (new here …)`.
- The 11 declared wide-population families (CI-owned) were also run,
because they walk `packages/**/*.ts` and this diff adds prose to
`registry.ts`: all exit 0.
- Lint, narrowed and proven: `eslint --no-inline-config --format json`
over the 4 changed `.ts` files → 4 files, 0 errors, 0 warnings.
Population read from eslint's own config: `--print-config` resolves for
all four; the changeset `.md` and the `.mdx` are outside it (`File
ignored because no matching configuration was supplied`). Invariance:
`eslint.config.mjs` enables no type-aware linting (the resolved
`parserOptions` carry only `ecmaVersion` / `sourceType`, no `project` /
`projectService`), so this diff cannot move a verdict on an untouched
file.
- Reverse verification against the rebuilt `dist/api/index.d.mts`: a
probe reading `PackageApiContracts.upgradePackage` /
`.resolveDependencies` / `.uploadArtifact` → `TS2339` on each (tsc exit
2); the control leg reading `.installPackage.path` alone → exit 0.
Direction observed: red, as expected.
- Ablation of the new pins (`scripts/ablation-replace.mjs`, wrap mode,
under the verify lock): re-planting an `upgradePackage` entry (anchor 1
→ 0, blob `d2f65040` → `9173ef90`) turned 3 of the 4 new tests red
(`expected true to be false`, `expected [ 'upgradePackage' ] to deeply
equal []`, `expected [ Array(5) ] to deeply equal [ Array(4) ]`); the
schemas-still-published test stayed green, as it should. Restore proven:
blob back to HEAD `d2f65040`, `git diff HEAD` empty. The restored leg:
`package-api.test.ts` 78/78 passed. ⚠️ The first ablation attempt was a
no-op (its replacement contained its anchor, so the tool refused before
running anything and restored); it is not counted.

## Acceptance notes

- **Per-route schemas (sections 5–7) are NOT deleted** — the ruling
names the map entries only. Consumers measured at `fdeeea0cc9`, tests
counted separately:

  | export | non-test consumers (this repo) | tests | objectui pin |
  | --- | --- | --- | --- |
| `PackageUpgradeRequestSchema` / `PackageUpgradeResponseSchema` | 0 | 1
(`package-api.test.ts`) each | 0 |
| `ResolveDependenciesRequestSchema` /
`ResolveDependenciesResponseSchema` | 0 | 1 each | 0 |
| `UploadArtifactRequestSchema` / `UploadArtifactResponseSchema` | 0 | 1
each | 0 |
| the 12 matching `…Request` / `…Response` / `…Parsed` types | 0 | 0 | 0
|

"Non-test consumers" excludes the generated artefacts that merely record
the exports (`api-surface/`, `export-origins/`, `declaration-map/`,
`authorable-surface/`, `json-schema.manifest/`,
`dropped-refinements.baseline.json`, `content/docs/references/**`) and
comment mentions in two retired-`mergeStrategy` migration entries.
Imports that only these sections use in this file: `UpgradePlanSchema`
(other consumers: its own `kernel/package-upgrade.zod.ts` and test) and
`PackageArtifactSchema` (also used by
`system/environment-artifact.zod.ts`). A follow-up can decide their fate
on these numbers.
- The `PackageApiContracts` docblock still says the map is "Used for
generating SDKs, documentation, and route registration"; nothing in this
repo registers routes or generates an SDK from it (0 readers outside its
own tests). Noted, not filed; carrier: whoever takes the sections 5–7
decision.
- `PackageApiErrorCode` still lists `upgrade_failed` and
`upload_failed`; no emitter outside `package-api.zod.ts` and its test.
Noted, not filed; same carrier.
- The weight-carrying pin is package-local (the map's keys and paths).
The page's absence of the three routes is held by `check:docs`, because
the page is a generated projection of the file-header docblock this PR
edits. A tree-scoped absence pin would need
`scripts/cross-package-test-inputs.mjs` and `turbo.json` edits outside
the claimed file surface, so it was not added.
- No labels were written: the dispatch's write budget names none, and
`skip-changeset` does not apply (a changeset is present).

## Files

`.changeset/19116-package-api-unmounted-entries-retired.md` ·
`content/docs/references/api/package-api.mdx` ·
`packages/spec/src/api/package-api.test.ts` ·
`packages/spec/src/api/package-api.zod.ts` ·
`packages/spec/src/migrations/entries/semantic/18.package-api-contracts-unmounted-entries-retired.ts`
· `packages/spec/src/migrations/registry.ts` — +208 / −43.

Dispatched by PM seat `domain:spec#4` (session
`session_019c3Hi6ZMU1p6m6aA6Bz45d`), claim comment 5804927566.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…only at both application doors (objectstack-ai#19620) (objectstack-ai#19945)

Fixes objectstack-ai#19620

Clause-②: no

## What is ruled, and what landed

Ruling-ref `5770445203` — batch objectstack-ai#210 item 2, letter **B**, maintainer
「210 同意」: `settings` leaves `TranslationItemSchema` together with the
singular alias `setting: 'settings'`; the item door refuses it at parse
with the platform-only prescription; the `settings` liveness row
retires; the D2 conversion `translation-per-app-settings-removed` learns
the item shape; the ADR-0087 semantic entry is extended. Step ① (the
production reading) was waived by the maintainer (records `5796717943`,
`5797554787`), so this PR runs step ② and then step ③, and the D2
item-shape conversion takes the **migrate** branch with a loud notice.
Nothing is folded into PR objectstack-ai#19600.

## Measured first: stored rows (dispatch assumption 3)

The question was whether teaching the conversion the item shape removes
a stored item's `settings` before the runtime reader merges it. **It
does not, and not for one reason but two.** Probes (scratch scripts, not
committed), read against this worktree:

```text
# at origin/main fdeeea0, spec dist built from it
[A] applyConversionsToStoredItem(translation, item-with-settings): settings survives = true ; notices = []
[B] readAuthoredTranslationLayer(raw row) -> layer[zh-CN].settings = {"mail":{"title":"我的邮件",...}}

# after the conversion learned the item shape (411513f), spec dist rebuilt
SINGULAR_TO_PLURAL.translation = undefined ; PLURAL_TO_SINGULAR.translations = undefined
[stored seam] settings survives = true notices = []
[chain over translations collection] settings survives = false notices = ["translation-per-app-settings-removed"]
```

1. `authored-translation-sync` reads `sys_metadata` itself and
deep-merged the RAW stored payload; it never called the conversion
chain.
2. Even the seams that DO call `applyConversionsToStoredItem` (the
metadata protocol's stored reads, `DatabaseLoader.rowToData`, `os
migrate meta --stored`) returned every `translation` row untouched: the
stored pass wraps a row in its stack collection, and the
manifest-collection maps carry no `translations` spelling. So no seam
had ever replayed ANY translation conversion over a stored row.

And the override is real: both i18n adapters read the runtime-authored
layer OVER the static bundles (`deepMerge(static, authored)` in
`packages/core/src/fallbacks/memory-i18n.ts` and
`packages/services/service-i18n/src/file-i18n-adapter.ts`), so a stored
item's `settings` beat the platform's own copy — the card's confidence
gap 2 is closed, and the core test below pins it end to end.

So the stored-row half lands in two places: the stored pass reaches
`translation` rows (spec, `conversions/stored.ts`), and the runtime sync
becomes a rehydration seam that calls it before merging (core,
`authored-translation-sync.ts` — the claim's conditional surface).

## Step ② — conversion, semantic entry, stored rows

- `packages/spec/src/conversions/registry.ts` —
`translation-per-app-settings-removed` walks the bare item shape too: an
entry carrying `locale`, or one with a declared translation group at its
top level (a row written before `locale` was required, which the sync
still reads by its name). Only the item's own top-level `settings` is
stripped; an object literally named `settings` under `objects` stays.
Surface, summary and docblock say both doors; the fixture gains the item
and a locale-less control.
-
`packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts`
— extended to both doors: the item OVERRODE the platform copy (a bundle
entry only filled gaps), so overridden keys go back to the platform
string and filled gaps to the manifest literal; `acceptanceCriteria` no
longer says the item is unchanged. `migrations/registry.ts` regenerated
with `gen:migration-registry`; the hand-written step-18 rationale
sentence that said the conversion never touches a `translation` item is
rewritten.
- `packages/spec/src/conversions/stored.ts` — `STORED_ONLY_COLLECTIONS`
maps a stored `translation` (and the legacy plural `translations`) row
to the `translations` collection. Kept out of the shared maps, which
`check:stack-collection-maps` holds to the stack schema.
- `packages/core/src/fallbacks/authored-translation-sync.ts` — each row
replays the full chain through `applyConversionsToStoredItem` before the
merge (the same policy as every other stored-read seam, PD objectstack-ai#12), and
each conversion is logged at `warn` once per row per wiring, naming the
row, the group (`'settings' → '(removed)'`), the conversion id, and `os
migrate meta --stored --apply`.
- Liveness: the `settings` row of
`packages/spec/liveness/translation.json` is deleted (strict-delete
route: the key left the walked shape, a surviving row would be an
ORPHAN). Its `_note` and the README's `translation` row say what the
deletion does NOT mean: the row's `live` evidence read the SERVED tree,
which the platform bundle feeds, so the platform capability is
untouched. `check:liveness` then named `translation/settings` a stale
row of the shrink-only `undrilled-containers.baseline.json`; it is
deleted. `state-counts.md` regenerated.

## Step ③ — the schema, the alias, the pins

- `packages/spec/src/system/translation.zod.ts` —
`TranslationItemSchema` spreads `appTranslationDataShape()` only (the
per-app face, ten groups); `setting: 'settings'` leaves its alias table;
`settings` and `setting` are answered by `ITEM_TRANSLATION_KEY_GUIDANCE`
with the item's own `ITEM_SETTINGS_PLATFORM_ONLY`, because the bundle
door's sentence ("the platform overwrote it anyway") is false for an
item. `settingsCommon` stays on both faces.
- `packages/spec/authorable-surface/system.json` —
`system/TranslationItem:settings` deleted deliberately (the check (a)
tripwire the strict-delete route owes). The build's check (c)
adjudicated it by proof 4:

```text
1 baseline deletion(s) since fdeeea0 carry their own proof (objectstack-ai#4650):
  - system/TranslationItem:settings — def reachable from the metadata-type roots; writing 'settings' on it is REFUSED as an unrecognized key
    and the refusal carries the prescription its `strictObject` declaration owes it ...
```

- Pins flipped (`packages/spec/src/system/translation.test.ts`): "still
accepts every declared group together" now asserts the item refuses
exactly one key, `[['unrecognized_keys', ['settings']]]`; "still accepts
it on the platform face" keeps the platform assertions and drops the
item one; a new block refuses `settings` and `setting` on the item
(issue path, keys, `PLATFORM group`, `PlatformTranslationData`, no
rename suggestion), refuses through `defineTranslation`, with a control.
- Door-level pin
(`packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts`,
section 4): saving a `translation` item carrying `settings` / `setting`
answers `code: 'INVALID_METADATA'`, `status: 422`, the issue names the
key and says `PLATFORM group`, and nothing is stored; a control saves
the same item without it. It rides that file's already-pinned engine
double, so the engine-double ledger does not move.
- Stored-row pins: `packages/spec/src/conversions/stored.test.ts` (both
row spellings drop `settings` with exactly one notice; a canonical row
passes through by reference) and the new
`packages/core/src/fallbacks/authored-translation-sync.test.ts` (dropped
before the merge, rest of the item kept, one warning naming
row/group/conversion, once per wiring, canonical-row control, and end to
end over `createMemoryI18n`: the platform's `邮件投递` renders, not the
stored override).
- Published prose made false by this change:
`content/docs/ui/translations.mdx` (said the item still declares it),
`content/docs/protocol/kernel/i18n-standard.mdx` (adds the item door),
`docs/qa/platform-checklist/areas/i18n.json` (anchor prose);
`content/docs/references/system/translation.mdx` regenerated with
`gen:docs`.
- `.changeset/19620-translation-item-settings-platform-only.md` —
`@objectstack/spec` and `@objectstack/core` `minor` (launch-window
convention), BREAKING banner, FROM → TO table, one-line fix, the
stored-row behaviour, ADR-0087 disposition `not-required
(already-registered …)` because both entries existed and are extended
here.

## PR objectstack-ai#19600's acceptance note is superseded

PR objectstack-ai#19600 (merged) records "`TranslationItemSchema` is UNCHANGED and
still declares `settings`" and, as its first acceptance note, "The
`translation` metadata-type door is untouched and still accepts
`settings`." **Both are superseded by this PR**: the item door refuses
`settings` with the platform-only prescription, and rows stored before
are converted at every stored seam. The seat carries this sentence to
objectstack-ai#19600 as a comment.

## ⚠️ One red gate by design — a pending release note corrected,
confirmation requested

`.changeset/15178-translation-bundle-split-settings-platform-only.md` is
unreleased and its "Unchanged" section said the registered `translation`
item still declares `settings` — false once this PR lands in the same
release. It is **corrected, not restored** (one sentence: not changed by
THAT entry, superseded in the same release by this PR's changeset).
`node scripts/check-empty-changeset.mjs --base origin/main` therefore
exits 1 in its DELIBERATE CORRECTION class, whose own text says the
remedy is to say so on the PR and get it confirmed. **Please confirm
this correction**; the alternative, restoring the file from base,
republishes the false sentence. No `skip-changeset` is involved.

## Declared file-surface deviations

The claim declared `translation.zod.ts` + tests,
`liveness/translation.json`, the conversion + semantic entry +
registries, regenerated artefacts, `.changeset/`, and conditionally
`authored-translation-sync.ts` (taken: measured necessary above).
Outside it, each forced rather than chosen:

1. `packages/spec/src/conversions/stored.ts` + `stored.test.ts` — the
stored pass returned `translation` rows untouched (measured above);
without it the migrate branch the ruling orders reaches no stored row.
Same defect class, a one-entry map; no open PR on it was checked (not
measured — ordinary concurrency).
2.
`packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts`
— the ruling's `code` + `status` pin lives at the metadata door, not in
the schema package.
3. `packages/spec/scripts/liveness/undrilled-containers.baseline.json` —
the shrink-only row `check:liveness` named stale once the key left the
shape.
4. `content/docs/ui/translations.mdx`,
`content/docs/protocol/kernel/i18n-standard.mdx`,
`docs/qa/platform-checklist/areas/i18n.json`,
`packages/spec/liveness/README.md` — published claims this change makes
false.
5. `.changeset/15178-…` — the correction above.

## Verification

Commits `411513f09` (step ②), `7ba3d25d9` (step ③), `d94e300e4`
(regenerated artefacts), `889861a04` (stored pass, door pins,
changeset), `b0f2b0ef0` (the changeset's ADR-0087 marker line only).

**Tests** (targeted, through `scripts/pm/os-verify-lock.sh`; run at
`889861a04` — `b0f2b0ef0` changes only the changeset marker, which no
test reads; the 422 file re-run at `b0f2b0ef0`):

| Package | Scope | Result |
| --- | --- | --- |
| `@objectstack/spec` | `translation`, `i18n-resolver`, `conversions/*`,
`migrations/*`, `retired-key-migrate-sentence`, `alias-integrity`,
`type-alias-convention.pin`, `metadata-plugin` | 14 files / 912 tests
pass |
| `@objectstack/core` | `fallbacks/authored-translation-sync.test.ts`
(new), `fallbacks/fallbacks.test.ts` | 2 files / 66 tests pass |
| `@objectstack/metadata-protocol` | whole package (dependency closure
built first) | 188 files pass, 3 skipped / 2676 tests pass, 19 skipped |
| `@objectstack/metadata-protocol` | the 422 file, verbose, at
`b0f2b0ef0` | 11 / 11, the three new cases named |
| `@objectstack/service-i18n` | whole package, against the rebuilt core
`dist` | 5 files / 74 tests pass |

**Typecheck:** `@objectstack/spec` (`tsc --noEmit` + scripts + test
layer), `@objectstack/core`, `@objectstack/metadata-protocol` — all exit
0.

**Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 116 commands on the actual diff (21
paths vs merge base `fdeeea0cc`); all 116 run at `b0f2b0ef0` with the
exit code recorded, and `--ran` reconciles: **116 derived, 116 run, 0
NOT-MEASURED, 0 UNRUN**. 115 exit 0; the one exit 1 is
`check-empty-changeset` (above). The whole workspace was built first
(`turbo run build --filter='./packages/*' --filter='./packages/*/*'`, 72
tasks) so the five built-output gates (`check:skill-examples`,
`check:dual-build-cjs-loads`, `check:i18n-walk-parity`,
`check:lean-entry-closure`, `check:type-check-debt`) measured instead of
exiting 3. `check:type-check-debt`: "4 ledger entr(ies) re-measured, 53
raw tsc error(s) total, none above its recorded number."

**Lint, narrowed and proven:** ESLint over the 10 changed `.ts` files,
`--no-inline-config --format json`: 10 files linted, 0 errors, 0
warnings. Population: the config's own globs
(`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`) take all
10 of the diff's script files. Invariance: `eslint.config.mjs` never
enables type-aware linting (its own comment: "no
`parserOptions.project`, no typed `@typescript-eslint` rules"), so this
diff cannot move a verdict on an untouched file. The repo-wide `pnpm
lint` is CI's.

**Ablations** (each through `scripts/ablation-replace.mjs`, after the
fix was committed; anchor hit 1 → 0 and a changed blob proved the
mutation on disk; each restore proved blob == HEAD and `git diff HEAD`
empty; the tests read `src/` by relative import, so no `dist/` was
involved). Direction predicted before running:

| Mutation | Predicted | Observed |
| --- | --- | --- |
| A — `...platformSettingsShape()` back on the item shape | the
`settings` pins red; the singular `setting` pin stays green (its
guidance still refuses it) | 3 red (declared-groups, `settings` refusal,
`defineTranslation`); `setting` green |
| B — stored pass without `STORED_ONLY_COLLECTIONS` | both stored-row
cases red, control green | 2 red, control green |
| C — sync merges without the chain replay | 4 core cases red, canonical
control green | 4 red, 1 green |
| D — conversion's item branch returns the entry | fixture replay +
stored cases red | 4 red across `conversions`, `stored`, `migrations` |

**Reverse verification of the rebuilt `.d.ts`:** a probe file in
`packages/core/src` typed `const rejected: TranslationItem = { locale:
'en', settings: … }` beside an `apps` control; `tsc --noEmit -p
packages/core` answered `TS2353 … 'settings' does not exist in type …`
on the `settings` line only; the probe was removed by a trap and the
tree read clean.

## Acceptance notes

- **Same-class correction riding the stored-pass change.** The docblocks
of `translation-validation-messages-removed` and
`translation-component-submit-label-removed` already claimed stored
`translation` rows replay through them; until this PR none did. Both
strip keys no resolver reads, so the only observable change for them is
a one-time stored-row warning when an old row is read.
- **What an operator sees.** Reading an old row that still carries
`settings` through the metadata API now serves it without the group and
logs the protocol's stored-row warning; the runtime sync logs its own
warning once. A Studio re-save or `os migrate meta --stored --apply`
persists the canonical row.
- **`setting` (singular) is not converted.** An alias only ever
suggested a rename in the rejection; the item door never accepted
`setting`, so no stored row can carry it.
- **The sync's warning is conversion-agnostic.** It names the row, the
dropped group and the conversion; the platform-only reason is carried by
the item-door refusal and by the D3 semantic entry (which `os migrate
meta --from 17` reports as a semantic TODO, per that command's own
docblock — not run here), not restated per conversion in the consumer.
- **Pinned sibling (objectui `62597c588`)** — `TranslationPreview.tsx`
still lists a `settings` group and `clientValidation.ts` prose counts
"19 keys". Neither breaks: the binding imports `TranslationItemSchema`
itself and its parity test compares the schema with itself; the preview
group simply never renders now. Stale prose / dead UI in the sibling;
carrier: none; not filed.
- Size: 21 files, +714 / −173 (887 changed lines), under the 5,000-line
human-merge threshold. No governed surface is touched.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…nce, and the loopback OAuth notice at info (objectstack-ai#22097)

Fixes objectstack-ai#22073
Clause-②: no

## What changes

Three boot-output changes, as the triage ruling on the card (comment
`6038562452`) directs. Log text and log levels only: no accept set,
answer or public signature moves.

1. **One line per warning class.** `printAutomationSummary`
(`packages/cli/src/utils/format.ts`) groups the engine's binding audit
by (trigger type, reason) and prints one line per class with its flows
listed. The class's short text is the reason's **first sentence**,
derived by `leadSentence()`. It is never a second, hand-written wording:
the engine owns every reason sentence (`describeUnboundReason`, and
`scheduledWorkDisabledReason` for the deployment policy). A reason that
is already one sentence (a missing trigger, a binding failure, a
declined subflow) prints whole, remedy included. Distinct reasons stay
distinct lines.
2. **Each warning prints once.** `printAutomationSummary` now returns
the identifying text of every logger record it restated, at the moment
it prints its own line. `printBootDiagnostics` withholds exactly those
and counts them in its header: `⚠ Boot diagnostics — 3 warnings logged
during startup (2 more already listed above):`. The shadowed-flow
bootstrap restatement is covered by the same rule. The pull-time `Flow
name collision` record still replays, because it says which rule armed
the body.
3. **Loopback OAuth notice at `info`.** In
`packages/plugins/plugin-auth/src/auth-plugin.ts`, `OAuth is served
UNENCRYPTED: …` logs at `info` when the issuer's host is loopback and
stays `warn` for private and link-local hosts. The sentence is the same
and is still emitted on every accepted plain-HTTP boot, so D1 of ruling
batch objectstack-ai#210 item 5 ("eligibility decides WHICH sentence, never WHETHER")
holds across both levels.

Where the long text now prints: at `--log-level debug` (or `info`) the
boot-quiet window never opens. The live stream then carries
`@objectstack/service-automation`'s per-flow `[Automation] flow 'NAME'
declares a 'TYPE' trigger but is NOT bound — it will never auto-launch.
REASON` warning, with the whole reason. When any class line was
shortened, one dim line under the classes says so.

### Measured, before → after (H1)

`examples/app-todo` has 2 package-authored `schedule` flows. Booted with
`os dev --fresh --no-watch`, scheduled work off (the default), default
log level:

| | before (`3d918850`) | after (this branch) |
| --- | --- | --- |
| lines carrying the schedule `NOT bound` sentence | **4** (2 banner + 2
*Boot diagnostics*) | **1** |
| `OAuth is served UNENCRYPTED` lines (localhost) | 1 (`WARN`, in *Boot
diagnostics*) | 0 |
| *Boot diagnostics* records shown | 6 | 3 (+2 counted as listed above)
|

The after banner line:

```text
  ⚠ 2 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — package-authored scheduled work is off on this deployment (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms and no packaged `defineJob` is scheduled: task_reminder, overdue_escalation
      reasons cut to their first sentence — --log-level debug prints each flow's full reason
```

The second producer was the one the PM named: the banner's `a.unbound`
loop, plus *Boot diagnostics* replaying `service-automation`'s
`kernel:bootstrapped` audit warning (`plugin.ts:1533`). There was no
third copy. By construction, the hotcrm shape (8 flows) goes from 16
lines to 1 class line plus 1 hint line. hotcrm itself was not booted
here.

### How a new boot warning stays single (H7)

*Boot diagnostics* withholds only a record that a banner section
**handed back as restated when it printed its own line**. Today the only
such section is the automation summary, and only for the
service-automation audit records it summarises.
- A new `logger.warn`, such as the branding-asset warning objectstack-ai#22071 adds,
is restated by no banner section. It is never matched, so it replays
once.
- A banner line that is not also a logger record has nothing to
withhold, so it prints once.
- The match can fail only toward printing. A reworded producer or an
escaped character in a JSON-format record is printed, never dropped, so
the failure mode is a doubled line, never a lost diagnostic.
- A banner section that starts restating a logger record must hand that
record back, or it doubles. That contract is documented on
`BootDiagnosticsReplayOptions.restatedAbove`.
- The failed-boot and migrate-and-exit paths print no banner and pass
nothing, so they replay every record.

## PM readings, measured

- **H1**: reproduced with the counts above. The two producers are the
ones named.
- **H2**: the CLI does both jobs, and `service-automation` is untouched.
Hosts that boot the kernel without the CLI banner read the service's
warning as their only notice:
`packages/runtime/src/domains/automation.ts` (the runtime composition
every adapter host uses), `packages/verify/src/harness.ts`, and the
plugin's own comment at `plugin.ts:1490`–`:1492` ("The warn matters for
embedded hosts and tests"). Lowering that warning, or grouping it, would
change what those hosts see. No `@objectstack/service-automation`
changeset.
- **H3**: classes are keyed on the whole reason, not its short form. The
deployment-policy reason is one class per trigger type. A real binding
failure, a missing trigger and a declined subflow each keep their own
line. The short-text derivation and the long-text location are described
above.
- **H4**: no other published origin feeds the decision.
`getAuthIssuer()`, `getMcpResourceUrl()` and `isMcpOAuthEnabled()` all
derive from the one `getCanonicalOrigin()`, so "every published origin
is loopback" reads as "the issuer's host is loopback". **Partly
falsified:** the transport rule has no separable loopback predicate to
reuse. `isOAuthEligibleBaseUrl` checks `localhost` / `*.localhost`
inline (`auth-manager.ts:558`), and `isPrivateOrLoopbackHostLiteral`
checks `127.0.0.0/8` and `::1` in the same function as the private
ranges. Extracting one means editing `auth-manager.ts`, which is outside
this card's declared file surface. So the notice's module-local
`isLoopbackIssuer()` composes the rule's own pieces: the same
`localhost` / `*.localhost` names, the `127.0.0.0/8` block judged by the
same ADR-0069 D5 matcher (`ipMatchesRange`, already exported), and
`[::1]`. It adds no new host regex, and it reads the WHATWG-canonical
hostname as the rule does. It is asked only of an issuer the rule
already accepted. Agreement is pinned by the loopback / private table
below. Extracting one shared predicate is left as an open question in
the report.
- **H5**: two assertions in `mcp-oauth-plaintext-notice.test.ts` flip,
both for the ruling's reason. `fires on a loopback deployment too —
plain HTTP is plain HTTP` (localhost: 1 `warn`) is replaced by `logs the
sentence at info, never warn, on a LOOPBACK deployment`, over
`localhost`, `app.localhost`, `127.0.0.1`, `127.0.0.2`, `127.1` and
`[::1]`, each 0 `warn` and 1 `info` naming the issuer. `every plain-HTTP
boot gets exactly one of the two sentences` now counts across both
levels; its localhost row would otherwise read 0. The control stays
`warn`: `is emitted at warn on a private address` (`172.16.0.1`), plus a
new `keeps the warning on a PRIVATE or LINK-LOCAL deployment` over
`10.0.0.5`, `172.16.0.1`, `192.168.1.10`, `169.254.10.20`, `[fd00::1]`
and `[fe80::1]`.
- **H6**: `packages/cli/src/commands/doctor.ts:289` is unchanged. Text
that quoted the old lines moved with them, outside `content/docs/**`:
- `docs/qa/platform-checklist/areas/platform-core.json`: the
`platform-core.boot-health` acceptance `verify` quoted the per-flow
banner line. Revision 6 → 7, with a history entry.
- `docs/qa/platform-checklist/areas/ai.json`:
`ai.mcp-oauth-private-host-transport` expected the accepted-transport
line "exactly 1 on (c)" (loopback) at the default level. It now names
the level per boot, and boot (c) runs at `--log-level info`. Revision 2
→ 3, with a history entry. `pnpm check:platform-checklist` is green.
- No `content/docs/**` page needed an edit. `automation/flows.mdx:2380`
and `deployment/environment-variables.mdx:91` say such flows are "listed
by … the CLI startup summary as disabled by deployment policy", which is
still true. `releases/v17/17-5.mdx` is release-owned and untouched.
`packages/spec/scripts/publish-smoke-boot-failure.test.ts` holds
verbatim historical specimens of the old header with no restated
records, which are still valid.
- **H7**: covered in the section above.

## Tests (at `de6b4a08`)

- New `packages/cli/src/utils/format.boot-warning-classes.test.ts`, unit
tier, 11 tests. The first block boots the **real**
`AutomationServicePlugin` on a `LiteKernel` with 8 `schedule` flows and
scheduled work off. It captures stdout through the same `BootLogCapture`
`serve` uses, collects through `collectAutomationSummary`, and prints
the real banner. Pins:
- exactly **one** line carries `a 'schedule' trigger`, and it names all
8 flows;
- every flow is named on exactly **one** line, so no warning appears
twice;
  - the long explanation is absent at the default level.

The premise is asserted first: the producer warned once per flow into
the capture. Formatter legs cover distinct classes, the first-sentence
cut, the hint only when shortened, a JSON-format record, a record the
banner did not restate (never withheld), the shadowed restatement
withheld while the collision record is kept, and the no-banner path
replaying everything.
- `pnpm --filter @objectstack/cli exec vitest run --project unit`: **260
files, 3797 passed**. `pnpm --filter @objectstack/cli typecheck`: exit
0. The new file is in `tsconfig.json`'s program (`--listFilesOnly`), and
`check:test-typecheck` is OK. The `integration` tier is declared to CI:
the diff touches no spawn entry, no integration-tier file and no driver.
- `pnpm --filter @objectstack/plugin-auth test`: **126 files, 2623
passed, 10 skipped**. `pnpm --filter @objectstack/plugin-auth
typecheck`: exit 0.
- **Ablations.** Each mutation went through
`scripts/ablation-replace.mjs` against the committed fix, with the
anchor hit verified on disk and the restore proven as blob == HEAD with
an empty `git diff HEAD`. The subjects resolve from source, so no
rebuild was needed.
- **A**: drop the unbound claim. 5 red: both real-boot pins, plus the
three formatter print-once legs that withhold unbound records.
- **B**: key classes by flow. 2 red: the one-schedule-line pin and the
classes leg.
  - **C**: never log at `info`. 6 red: every loopback spelling.
- **D**: treat every host as loopback. 10 red: every private and
link-local control.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --ran` reports **72
derived, 72 run, 0 NOT-MEASURED, 0 UNRUN** at `de6b4a08`. The derivation
added `pnpm --filter @objectstack/lint run
check:doc-formula-expressions` and `pnpm check:platform-checklist` to
the PM's list. `check:dual-build-cjs-loads` and `check:i18n-coverage`
first exited 3 (PREREQUISITE NOT MET, unbuilt workspace packages). After
`pnpm turbo run build`, they and the other dist-reading gates were
re-run, all exit 0. Full `pnpm lint` exits 0 at `de6b4a08`. Narrowed
eslint (`--no-inline-config --format json`) over the 4 changed TS files:
0 errors, 0 warnings. The other 3 changed files are outside eslint's
population ("no matching configuration"), and the config enables no
type-aware linting (`eslint.config.mjs:327`).

## Acceptance notes (not filed)

- *Boot diagnostics* still pairs the ready line `⚠ Server is ready —
DEGRADED: missing core services: …` with the kernel's `System started
with degraded capabilities …` record. This is deliberately left: it is
the ready-line status, not a banner-list entry, and
`serve-ready-degraded-boot.e2e.test.ts` asserts the kernel sentence in
the same output as its premise.
- A flow declined onto a disabled packaged subflow also has a bind-time
engine `warn` (`Flow 'NAME' is registered but NOT armed on trigger
'TYPE' — …`). That is a different record from the bootstrap audit, so it
still replays beside the banner's class line. The case is rare, and the
record carries the subflow detail.
- The service's own bootstrap sentence reads "is NOT bound — it will
never auto-launch. disabled by deployment policy — … This is not a
binding failure" for an embedded host. That is a wording observation,
unchanged here (H2).

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch had an error being deployed

1 failed deployment
Preview — af51e143 Deployed Jan 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants