Skip to content

docs(protocol): the error-handling page documents BOTH live error envelopes and states the door boundary - #18191

Merged
claude[bot] merged 5 commits into
mainfrom
claude/issue-15632-error-envelope-both-families
Sep 14, 2026
Merged

claude[bot] merged 5 commits into
mainfrom
claude/issue-15632-error-envelope-both-families

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #15632

Clause-②: no
Docs-only. No packages/** byte changes, no runtime surface widened, no new authorable key — the whole diff is one hand-written MDX page under content/docs/.

What the ruling asked for

Decision batch #107 item 3 (comment 5596253788), option A, maintainer reply verbatim: 「其他同意」.

Ruled. content/docs/protocol/kernel/error-handling.mdx stops claiming one universal envelope. The nested section is corrected to the fields the nested writers actually emit (no details / requestId / timestamp unless measured on a writer), a flat ADR-0112 section is added for the /data family, and the page states the boundary: which route families answer nested (/meta/*, the sendError writers in packages/types/src/response-envelope.ts) and which answer flat (packages/rest/src/error-response.ts structuredCodeAnswer / resolveErrorResponse). Every per-code example is placed under the section its route family belongs to. B (narrow the page to /data, flat page-wide) and C (keep nested, fix examples only) refused. Routes are not changed by this card.

Routes are not changed here. Zero bytes outside content/docs/.

Every shape published, traced to its writer

The ruling's execution note requires each envelope claim to be measured against a writer before it is written. Each claim in the new page and the writer it was read off:

Claim now on the page Writer it was measured against
nested body is { success: false, error: { code, message, ...extra } } sendError, packages/types/src/response-envelope.ts:228
nested extra is exactly category · httpStatus · details · requestId · declaredCode · userMessage the Pick<ApiError, …> on sendError's signature, same file :209
nested details is emitted 9 details: extras across the 7 sendError route modules; buildApiError's details split; the rate limiter's { retryAfterSeconds, resetAt }
nested httpStatus is written by the dispatcher writer, not by sendError buildApiError sets it unconditionally (packages/runtime/src/error-envelope.ts:119-126); no sendError call site passes one (measured over the same 7 modules that yielded the 9 details: hits — same grep, same population, so the zero has a firing control)
⛔ nested carries no requestId zero requestId passed into sendError's extra or buildApiError's extra/details at any call site. Firing control: details: fires 167× in the same population
⛔ nested carries no timestamp no timestamp: in error-response.ts, response-envelope.ts or error-envelope.ts
flat body is { error: <string>, code?, …siblings } structuredCodeAnswer arms + the generic passthrough return, packages/rest/src/error-response.ts:2384-2392
flat code is optional thrownCodeFields returns {} when the producer named no code (:493-498)
flat siblings: field, fields, developerMessage, dependentObject, dependentCount, currentVersion, currentRecord, datasource, reason, object, issues, declaredCode, userMessage the seven structuredCodeAnswer arms (:1007-1270) + withDeclaredUserMessage (:708-715)
UNIQUE_VIOLATION flat-only built by the DUPLICATE_RECORD arm, :1229-1245
DELETE_RESTRICTED flat-only, two sentences, dependentCount absent when withheld the arm at :1007-1030 + the throw site packages/objectql/src/engine.ts:13786-13825 (discloseCount gates the key)
INVALID_FIELD flat-only, field/object as siblings the arm at :1255-1269
RATE_LIMIT_EXCEEDED nested-only, with httpStatus + details packages/runtime/src/security/inbound-rate-limit.ts:353-361; code derived via HttpStatusErrorCodeMap[429]
VALIDATION_FAILED is the per-field code, fields a top-level sibling the arm at :1149-1160; entry shape from FieldValidationError (packages/objectql/src/validation/record-validator.ts:102-133); code values from FieldErrorCode (packages/spec/src/api/errors.zod.ts:233-277)
VALIDATION_ERROR is the status-derived 400 code HttpStatusErrorCodeMap (errors.zod.ts:173)
EXPIRED_TOKEN is not emitted by anything its only non-test occurrence repo-wide is the StandardErrorCode registration at errors.zod.ts:71. Firing control: INVALID_TOKEN, same grep, same population, returns two real producers in storage-routes.ts
the flat /data 403 for a shared-record write is FORBIDDEN, not PERMISSION_DENIED packages/plugins/plugin-sharing/src/sharing-plugin.ts:1337-1344
the CODE: prefix is stripped before the body is written withoutDeclaredCodePrefix, error-response.ts:537
5xx prose is withheld unconditionally, not filtered the 5xx arm at :2250-2259 (INTERNAL_ERROR_MESSAGE = 'Internal server error', packages/types/src/error-leak.ts:38)
request id is a response header packages/runtime/src/observability/instrument.ts:103-121 (res.header(requestIdHeader, requestId), default X-Request-Id); inbound echo is validated first — extractRequestId, request-context.ts:40-52

One measurement that corrects the brief

Zone 2's map said /meta/* answers nested. That holds for the runtime dispatcher's /meta door only. The @objectstack/rest /meta registrars report every thrown error through handleRouteError → resolveErrorResponse, i.e. flat — 18 handleRouteError(res, error) call sites inside registerMetadataEndpointsInner's range, against zero sendError calls there. So the same INVALID_REQUEST spelling refusal (packages/metadata-protocol/src/protocol.ts:274-283, a bare throw with .code + .status) answers nested through the dispatcher — which is what error-catalog.mdx's /meta examples publish, httpStatus and all — and flat through @objectstack/rest.

The page states this as the rule rather than as a path list, which is what Zone 3 asked for: the envelope is decided by the door, never by the code; a refusal a writer builds is envelope-exclusive, a refusal that is thrown takes whichever door caught it. The /meta split is called out explicitly in its own callout.

The page also discloses, without enumerating, that the @objectstack/rest door still carries tracked non-conforming bodies, and names pnpm check:route-envelope as the authority — rather than publishing a ratchet count that goes stale.

Verification

Gates — 41/41 green, derived with no path list (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack), exit codes captured to disk before any pipe, reconciled with --ran:

Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
✓ dispatch-gates --ran: 41 derived famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED zero — all 41 recorded an exit code and none of them is 3).

The first sweep produced five non-zero results, all of them PREREQUISITE NOT MET, none a finding: check:doc-formula-expressions and check:doc-security-posture exit 3 on an unbuilt @objectstack/lint; check:docs-transcript-drift exit 3 on the same; check:docs exit 1 on a missing packages/spec/json-schema; check:skill-examples exit 1 on packages/spec/dist and then again on packages/client-react/dist. After building those packages the whole sweep was re-run, not just the five. Builds went through OS_VERIFY_LOCK_SLOT=issue-15632 bash scripts/pm/os-verify-lock.sh, all three VERDICT command-exit 0.

Lint — full, not narrowed. eslint . --no-inline-config --format json ran over the whole repo in one foreground pass: exit 0, 6752 files, 0 errors, 0 warnings. No narrowing claim is needed, and the file this PR touches is not in eslint's population at all (.mdx matches none of the config's files: globs — 0 of the 6752 reported files end in .mdx).

Tree. Derived and run on 50d799c03, which already merges origin/main (the derivation's earlier STALE TREE warning is gone, and the derived family list is byte-identical before and after the merge).

Control characters. grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the edited file: no match. pnpm check:nul-bytes exit 0.

skip-changeset. Measured on the publish surface, not asserted: of the 70 non-private workspace packages, zero list anything under content or docs in files[], and apps/docs is "private": true. Positive control: the same reader prints @objectstack/spec's real files[] array, so the zero is a reading. Nothing in this diff ships in a tarball.

Acceptance notes

Observed while measuring, not filed and not fixed here — none is a reproducible defect, a contract violation, or a metadata-authoring trap:

  • rest-server.ts's registerMetadataEndpointsInner mixes four response shapes on one prefix — handleRouteError's flat body, hand-built { error: { code, message } } with no success, { code, message } with no error key at all (respond501), and respondError → the shared nested sendError. This is exactly the drift check:route-envelope's dialectOnly ratchet already tracks against #9559, so it has a successor and needs no new card.
  • The two query-parameter refusal helpers (query-allowlist.ts, query-multiplicity.ts) each write one { error: { code, message } } body with no success above it. Already carried in the same gate as unenveloped: 1 apiece, ratcheted to #9559.
  • The /data door drops a thrown error's details bag (the sharing plugin sets one at sharing-plugin.ts:1343; the generic 4xx passthrough does not relay it). Noted, not filed — the flat envelope has no details slot by design, so this is the design working, not a leak.
  • EXPIRED_TOKEN is a registered StandardErrorCode with no producer — the same declared-but-dead shape QUOTA_EXCEEDED already carries on this page. Noted, not filed: the ADR-0049 enforce-or-remove question for the error-code catalog is a packages/spec decision, not a docs one, and no PR or person is heading for it right now. Successor: none.

维护者速读(草稿)

改了什么:协议文档《错误处理》整页重写。此前开篇写「每个错误都长这样」并给出一个嵌套信封,页面上十几条 per-code 示例全部重复该嵌套形状 —— 而平台实际上有两套活的错误信封。现在页面如实描述两套:新增「信封由哪一道门写决定,不由错误码决定」这条规则、两节分别写明嵌套与扁平各自的真实字段、一张路由族对照表,并把每一条 per-code 示例放回它真正所属的那一族。

为什么改:裁决 A(决策批次 #107 第 3 项,维护者原话「其他同意」)。此前照这页实现的客户端在 /data 一族上整条解包路径都是错的 —— 会去读 body.error.code,而线上给的是 body.code。页面还发布了三个任何写手都不发射的字段(details 在嵌套上是真的,但 requestId / timestamp 两者皆无),以及一个零生产者的错误码 EXPIRED_TOKEN。

风险与代价(含回滚):纯文档,零运行时影响,content/docs/** 不进任何已发布包(已在发布面实测)。回滚 = revert 这一个 commit。真正的代价是这页现在更长(955 → 1330 行):两套信封、一条判别规则、逐码归属,写全就是这个体量。若嵌套与扁平将来收敛为一套,这页可大幅收缩 —— 但那是一张协议卡,本次裁决明确没有开(未给 A+)。

席位意见:(留空,待席位定稿)

你要做的:如果同意「两套信封如实公开」这个方向,直接 review 合并即可。如果你希望开一张「收敛为一套信封」的协议卡,那是独立的一张,本 PR 不阻塞它。

🤖 Generated with Claude Code

https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk


Generated by Claude Code

…page

Envelope section, auth codes and the validation codes, each measured against
its writer.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…and security examples

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
@claude
claude Bot marked this pull request as ready for review September 14, 2026 14:52
@claude
claude Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit d4554d4 Sep 14, 2026
34 checks passed
@claude
claude Bot deleted the claude/issue-15632-error-envelope-both-families branch September 14, 2026 15:10
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ain-red premise is falsified (the failure is PR objectstack-ai#18191’s own) (objectstack-ai#18196)

Fixes objectstack-ai#18193

Clause-②: no
Docs-only. One hand-written MDX page under `content/docs/`; no
`packages/**` byte changes, no runtime surface widened, no new
authorable key.

## ⚠️ The card's premise is falsified: `main` is NOT red — the failure
belongs to PR objectstack-ai#18191

The card diagnoses a latent red on `main` and rests that on one
mechanism claim: that `face.wireCodes` is derived from CODE only, so "no
docs edit can move it". **That claim is wrong, and it is the whole
diagnosis.**

`deriveWireFace` (`scripts/check-error-status-conformance.mjs`)
reconciles `StandardErrorCode` with *every other code a scanned page
publishes a status for* — and it scans **two** pages:

```js
const DOC_HANDLING = 'content/docs/protocol/kernel/error-handling.mdx';   // :219
const DOC_CATALOG  = 'content/docs/api/error-catalog.mdx';                // :220
```

`error-handling.mdx` is exactly the page PR objectstack-ai#18191 rewrites. The test
file itself never reads it — which is what the card measured — but the
derivation it imports does, one frame down. `reconciledVocabulary`'s own
docblock calls that second half "the whole point": a ledger code is
reconciled **because the docs publish a status for it**.

### Measured, four trees, same script

| tree | `wireCodes.length` | `docPublishedBeyondStandard` | wire codes
with no catalog heading |
|:--|:--:|:--|:--|
| `origin/main` @ `076bb975d` (today) | **51** | `INVALID_REQUEST`,
`UNIQUE_VIOLATION` | *(none)* |
| `main` @ `ba9b4981c` (PR objectstack-ai#18191's own base) | **51** | same two |
*(none)* |
| PR objectstack-ai#18191 head `50d799c03` | **52** | + `VALIDATION_FAILED` |
`VALIDATION_FAILED` |
| this branch | **52** | + `VALIDATION_FAILED` | *(none)* |

The provenance line the run prints for the 52nd code on the PR head
names the writer outright:

```
claimed VALIDATION_FAILED = [[400, ["content/docs/protocol/kernel/error-handling.mdx:346"]]]
```

That line is in PR objectstack-ai#18191's diff. And the test is green on `main`, twice
measured:

```
BEFORE (unmodified tree, 076bb97)   Test Files  1 passed (1) · Tests  5 passed (5)
AFTER  (this branch)                  Test Files  1 passed (1) · Tests  5 passed (5)
```

### Ablation — the card's two assertions, reproduced byte-for-byte, only
with objectstack-ai#18191 present

Both files swapped under a trapped restore (absolute paths; each
mutation proven on disk by a changed `git hash-object`; restore proven
by `git checkout HEAD -- PATHS` reproducing the HEAD blob hashes and an
empty `git diff HEAD`):

```
MUTATED handling=d174e08b8b93f10891366716e3677458f073944b catalog=6a529b3d11dd502d94c18c6c10dcd6f7f217e95b
        (handling hash == `git rev-parse probe-18191-head:content/docs/protocol/kernel/error-handling.mdx` — identity, not a grep)
catalog claim now: **51 error codes reachable on the wire**
catalog VALIDATION_FAILED headings: 0

AssertionError: wire code `VALIDATION_FAILED` has no catalog entry: expected false to be true
 ❯ src/api/error-catalog-docs.test.ts:95:82
AssertionError: expected 51 to be 52
 ❯ src/api/error-catalog-docs.test.ts:125:31
 Test Files  1 failed (1) · Tests  2 failed | 3 passed (5)

RESTORED both files == HEAD blobs · git diff HEAD empty
```

⚠️ One honest miss in that probe: the first swap also printed a `grep
-c` marker count of **0**. The marker string was mine, invented from the
PR's summary rather than read off its diff, so it never existed. The
binding evidence is the blob-hash identity above, which fired; the grep
proved nothing and is reported rather than quietly dropped.

### So why land this anyway, rather than returning a falsified-premise
report with no PR

The ruling's *intent* is served and the repair is correct on its own
terms — it just is not a `main`-red stanch:

1. **The page already told readers to branch on a code it never
catalogued.** `INVALID_REFERENCE`'s **Fix** line says *"branch on
`VALIDATION_FAILED` + `fields[].code === 'reference_not_found'`"*, the
dangling-reference callout publishes its full JSON body, and the
`MISSING_REQUIRED_FIELD` callout names `400 VALIDATION_FAILED`. Seven
prose mentions, zero entries. That hole is real with or without objectstack-ai#18191.
2. **It is green in both worlds, because the catalog page is itself one
of the two scanned inputs.** Adding the entry under `## Validation
Errors (400)` is what admits the code to the vocabulary, so the count
moves 51 → 52 **on `main` alone** and stays 52 once objectstack-ai#18191 lands.
Verified on a throwaway merge of this branch with `probe-18191-head`:
`wireCodes.length = 52`, zero wire codes missing a heading, and the real
test run **5 passed (5)** with objectstack-ai#18191's page swapped in.
3. **It unblocks objectstack-ai#18191**, which the seat deliberately did not arm for
this reason.

⇒ If the intent was specifically "stanch a red on `main`", there is
nothing to stanch and this can be closed unmerged at no cost. Everything
below stands on its own.

## The entry, traced to its writers

⛔ Not a placeholder row added to satisfy a count. Every claim measured
on this tree:

| Claim now on the page | Writer it was read off |
|---|---|
| flat `/data` body is `{ error, code: 'VALIDATION_FAILED', fields:
[...] }`, `fields` a **top-level sibling** | `structuredCodeAnswer`'s
arm, `packages/rest/src/error-response.ts:1150-1159` |
| flat `fields` is **always present**, `[]` when the producer named none
| same arm: `fields: Array.isArray(error?.fields) ? error.fields : []`
(:1156) |
| the nested door carries the list at `error.details.fields` instead |
`validationFailureDetails` returns `{ code, fields }`
(`packages/types/src/validation-failure.ts:57-63`); `splitSemanticCode`
**promotes** `code` to `error.code` and leaves the rest as `details`
(`packages/runtime/src/error-envelope.ts:100-126`) |
| a route that **builds** its own refusal sends the code with **no
`fields` at all** | `sendError(res, 400, 'VALIDATION_FAILED', 'object
and recordId are required')`,
`packages/plugins/plugin-sharing/src/share-link-routes.ts:156` |
| it is the code a **producer names**, unlike `VALIDATION_ERROR` which
is **status-derived** | `HttpStatusErrorCodeMap[400]` for the latter;
the former is matched on `err.code === 'VALIDATION_FAILED' \|\| err.name
=== 'ValidationError'` |
| status is **400 and only 400** | the derivation's own emitted census:
`emitted[VALIDATION_FAILED] = [[400, [...]]]` — `protocol.ts`
`MalformedVersionTokenError`, `protocol.ts:2721`, `protocol.ts:13012`,
`share-link-routes.ts:156` |
| covers a batch row naming no record id | `rowRequiredIdError`,
`packages/metadata-protocol/src/protocol.ts:2719-2724` |
| `fields[]` entry keys (`field`, `code`, `message`, `label`,
`constraint`, `value`, `options`) | `FieldValidationError`,
`packages/objectql/src/validation/record-validator.ts:102-133`; `code`
from `FieldErrorCode` (ADR-0114) |

The count was read off `face.wireCodes.length` **after** the edit (52)
and not transcribed from the card, per its acceptance criterion 2.

## Verification

**Gates — 41/41 green**, derived with no path list (`node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`), exit codes captured to disk before any
pipe, reconciled with `--ran` in the `:: exit N` form:

```
Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
✓ dispatch-gates --ran: 41 derived famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED zero — all 41 recorded an exit code and none of them is 3).
```

Exit-code census: **41 × exit 0**, zero non-zero, zero exit 3. `pnpm
check:error-status-conformance` — the gate most exposed to this edit,
since it is the same derivation — is one of them. Two prerequisite
builds ran first (`@objectstack/spec` + `@objectstack/lint`, then the
`@objectstack/client-react...` closure) through
`OS_VERIFY_LOCK_SLOT=issue-18193 bash scripts/pm/os-verify-lock.sh`; the
second printed `VERDICT command-exit 0`.

**Lint — full, not narrowed.** `eslint . --no-inline-config --format
json` over the whole repo in one foreground pass: **exit 0, 6752 files,
0 errors, 0 warnings**. No narrowing claim is needed. This file is not
in eslint's population at all — **0** of the 6752 reported files end in
`.mdx`.

**Control characters.** `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'`
over the edited file: no match (grep exit 1, zero lines). `pnpm
check:nul-bytes` exit 0.

**`skip-changeset` — measured on the publish surface, not asserted.** Of
the **70** non-private workspace packages, **zero** list anything under
`content/` or `docs/` in `files[]`, and `apps/docs` is `"private":
true`. Positive control: the same reader prints `@objectstack/spec`'s
real `files[]`
(`["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]`),
so the zero is a reading and not an empty walk. Nothing in this diff
ships in a tarball.

**Tree.** Derived and run on `6f3523462`, branched from `origin/main`
`076bb975d`.

## Acceptance notes

Observed while measuring, **not filed and not fixed here** — none is a
reproducible defect, a declared-contract violation, or a
metadata-authoring trap:

- **Editing `error-handling.mdx` silently widens the wire face and reds
a test about `error-catalog.mdx`.** A docs author on the protocol page
has no local signal that publishing a status for a ledger code enlarges
the catalog's advertised count. It is loud where it matters (the
assertion names the code and the arithmetic) and it is the mechanism
`reconciledVocabulary` was deliberately built to have, so this is the
design working rather than a defect. **Successor: PR objectstack-ai#18191**, which is
the PR that hit it and now has the repair landing alongside.
- **The count sentence is self-referential.** Adding a catalog entry for
a *ledger* code moves `wireCodes.length` by exactly +1 through the same
edit, so "add the entry" and "fix the number" are one change, not two.
Worth knowing before reading a `51 → 52` diff as two independent claims.
Noted, not filed — no rule is violated and the gate is loud.
**Successor: none.**
- **`VALIDATION_ERROR` and `VALIDATION_FAILED` are adjacent in the
catalog and easy to confuse**; the new entry states the distinction
(status-derived vs producer-named) rather than leaving it to the reader.
This is fixed here, not deferred — noting it only because the same
confusion is visible in `packages/cli/src/utils/format.ts:236`'s
comment, which is correct and needs nothing.

## 维护者速读(草稿)

**改了什么**:错误码目录页 `content/docs/api/error-catalog.mdx` 补上
`VALIDATION_FAILED` 这一条(此前它在页面正文里被提到七次,却没有自己的条目),并把页头「51 error codes
reachable on the wire」改成实测的 52。

**为什么改**:两件事,要分开看。① 这一页本来就有洞 —— `INVALID_REFERENCE` 条目明写「请改为 branch on
`VALIDATION_FAILED`」,而目录里根本查不到这个码。② 派单说 `main` 红了,**这一条经实测不成立**:`main`
上这个测试是绿的(今天的 `076bb975d` 与 PR objectstack-ai#18191 自己的 base `ba9b4981c` 都测过)。真正会变红的是
PR objectstack-ai#18191 —— 它重写的 `error-handling.mdx` 恰好是 `deriveWireFace`
扫描的两个页面之一,该页新写的 `VALIDATION_FAILED` 400
把这个码带进了线上词表,目录页却没跟上。卡片认定「文档改动移不动词表」,这一点是错的。

**风险与代价(含回滚)**:纯文档,零运行时影响,`content/docs/**` 不进任何已发布包(70 个非私有包的 `files[]`
实测,带正控)。回滚 = revert 这一个 commit。因为目录页本身就是词表的输入之一,这个改动在「objectstack-ai#18191 已合」和「objectstack-ai#18191
未合」两种世界里都是绿的 —— 两种都实跑过。

**席位意见**:_(留空,待席位定稿)_

**你要做的**:如果你认同「目录页该有这一条」,直接 review 合并即可,它同时解开 PR objectstack-ai#18191 的阻塞。如果你当初批的是「去把
main 的红止住」,那没有红可止 —— 这张 PR 可以零代价关掉,证据都在上面。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… start of its own line — the enqueue gate reads that comment only (objectstack-ai#17800) (objectstack-ai#18208)

Fixes objectstack-ai#17800

## What changed

`.claude/skills/pm-dispatch/SKILL.md` 〈执行座位职责〉 › 认领(先认领后动工), lines
:472–:474 on base `99edfd008e`: three lines → three lines. 812/812 held;
every changed line ≤ 120 bytes. Nothing else in the tree moves — ⛔ not
the 〈模板与表〉 template (P5), ⛔ not `scripts/pm/check-clause2-carriers.mjs`,
⛔ not the reader tolerance that objectstack-ai#17098 is about (objectstack-ai#17098 remains open), ⛔
no label, sweep or gate, ⛔ not `references/core-rules.md` (P3).

```diff
-- ② 认领评论(英文),固定形状见 模板与表 节;首行以字面 `Claim:` 开头是机器判据。
-- 巡查谓词只认 `Claim:` 这一个拼写且保持严格,修法是全舰队向它收敛,⛔ 不放宽谓词。
-- session ID 不可省;`mode:subagent` 的 dev 与 PM 同会话同 ID,甄别身份是分支。
+- ② 认领评论(英文)照抄 模板与表 节:`Claim:` 首行,`Branch:`、`Clause-②:` 各占一行且行首。
+- 条款②入队闸门只读认领评论且只读行首键:句中提及与另发评论皆读不到,卡即不可读。
+- 谓词保持严格、⛔ 不放宽;session ID 不可省,`mode:subagent` 下 dev 与 PM 同 ID,身份看分支。
```

Bytes per line (`Buffer.byteLength`, utf8, measured
2026-09-14T15:05:43Z): before 109 / 114 / 94 → after **116 / 118 /
114**; the cap is `MAX_LINE_BYTES = 120` in
`scripts/pm/check-skill-line-ratchet.mjs`.

What each new line carries:

- **:472 — the binding shape.** The claim comment copies the template:
`Claim:` is the first line; `Branch:` and `Clause-②:` each occupy a line
of their own, at the start of the line. 「照抄」 (copy) is the remedy the
checker's own remedy text names — "COPY the template's line rather than
composing one" (`TEMPLATE_POINTER`, check-clause2-carriers.mjs :1502).
- **:473 — the why, in the line itself.** The clause-② enqueue gate
reads only the claim comment and only line-initial keys; a key mentioned
inside a sentence (the dispatch-order bullet form measured on objectstack-ai#17234,
objectstack-ai#17620 and objectstack-ai#17948 — an instruction that describes the key inside a
sentence IS the violation) or posted as a separate comment is unread,
and the card is then illegible. 「条款②入队闸门」 is the charter's own name for
it (:637).
- **:474 — old :473 + old :474, compressed, content kept.** The readers
stay strict and are never widened (the standing 「⛔ 不放宽谓词」); the session
ID is mandatory; under `mode:subagent` the dev and the PM share one ID,
so identity is the branch. Two clauses dropped as now-redundant: 「只认
`Claim:` 这一个拼写」 — the literal spelling is shown in :472 and in the
template header (:799) — and 「修法是全舰队向它收敛」 — converging on the template
is what 「照抄」 in :472 says.

## Triage direction (comment 5645818231, verbatim)

> wherever the charter states the claim form, make the three lines
non-optional (`Claim:` / `Branch:` / `Clause-②:` each at the start of
its own line in the claim comment) and say why (the gate reads the claim
comment only); ⛔ not the checker (right on both limbs); ⛔ not objectstack-ai#17098
(the reader's tolerance — a different defect)

Where the charter states the claim form: SKILL.md :472 (the
claim-discipline line) and 〈模板与表〉 :799–:811 (the template, already
three-line). The rule line was the gap; the template was not.

## Premises, read against `origin/main` `99edfd008e` before writing

**P1 — holds in substance; the instrument's predicted count was wrong.**
:472 and :475 read exactly as the dispatch quoted (2026-09-14T14:57Z).
The grep `各占一行|各自一行|own line|行首` over SKILL.md, `references/*.md`,
os-dev.md and AGENTS.md returned **5 hits, not 0** (14:57:17Z; the
control `机器判据` in SKILL.md → 2, as predicted). Each read: SKILL.md:111
(`Maintainer-action:` line-initial — a different key),
state-machine.md:25 and :42 (`Blocked-by:` / `Release:` — different
keys), platform-readings.md:352 (`CLAIM_COMMENT_MARKER` recognises
line-initial `Claim:` — the marker, not the declaration), and
platform-readings.md:350–:351 「认领腿 `CLAUSE2_KEY_LINE`
的轴是行首位置:引用块、项目符、粗体、反引号全容,冒号可在外。只有 `#` 标题与行中键破它」 — a READING of the
checker's axis in the readings reference, not a rule binding the claim
comment's shape, and not at the place the charter states the claim form.
No line said the gate reads the claim comment only. The rule gap the
card names is real; the new :473 is its first rule-layer statement and
agrees with that readings line.

**P2 — falsified in its literal form; direction unchanged, "why"
stronger.** `grep -c 'Branch:' scripts/pm/check-clause2-carriers.mjs` →
**23** (14:57:17Z). The checker DOES read the claim's `Branch:` line: it
imports `claimedBranches` from `check-half-states.mjs` (:608), whose
reader is line-anchored —
`/^\s*(?:[-*+]\s+)?>?\s*Branch(?:es)?\s*:\s*(.*)$/gim`
(check-half-states.mjs :4789) — and `governingClaim` picks "the newest
claim comment from which at least one branch parses" (:4802). An inline
`Branch:` parses to zero and the pair is reported UNJUDGED with the
remedy "a `Branch:` line of its OWN … ⛔ not inside the `Claim:`
sentence, which no reader parses" (check-clause2-carriers.mjs
:3194–:3200, pinned at :5209–:5221). So the gate reads ONE comment and
all three keys line-anchored: `Claim:` as the marker
(`CLAIM_COMMENT_MARKER`, check-half-states.mjs :1134), `Branch:` to
resolve which claim governs the pair, `Clause-②:` as the declaration
(`CLAUSE2_KEY_LINE`, :762). The new :473 says exactly that —
「只读认领评论且只读行首键」 — and neither claims nor denies anything beyond it; :479
「分支名必须带 issue 号」 is untouched. Reported per the three-zone rule
(mechanism assumption tested, falsification reported, ruling's intent
kept), not as a stop.

**P3 — SKILL.md only.** `references/core-rules.md` :109 reads
「认领原子对:一次标签写入完成认领与状态对调,随后留 `Claim:` 开头的评论并重读全线程。」 (151/151, headroom 0).
It states the atomic pair and the `Claim:`-initial comment and nothing
about the other two keys' placement, and the new :472 leaves that
sentence true. The placement of `Branch:` / `Clause-②:` is form detail
of the template (细则), not a principle (核心条款); 〈优先级〉's same-PR sync
clause 「一条规则在本文与核心条款一处改动,另一处同 PR 同改」 fires on a rule that lives in both
files and changes in one — no core-rules sentence changed meaning.
Touching core-rules would have cost a trim in a 0-headroom file for a
detail; not taken.

**P4 — holds.** Base `pnpm check:pm-skill-ratchet` exit 0 at
2026-09-14T15:00:16Z: 「.claude/skills/pm-dispatch/SKILL.md is 812 lines
(ceiling 812; headroom 0)」, 「widest table row is 342 bytes (pin 342;
headroom 0)」; base `pnpm check:skill-frame-sync` exit 0 at 15:00:17Z.
`git ls-remote --heads origin` at 15:13:32Z: only
`claude/issue-17800-claim-form-three-lines` matches
`issue-17497|issue-17800` — no objectstack-ai#17497 branch exists. After the edit
`origin/main` advanced by one commit (`d4554d4f5a`, objectstack-ai#18191, a
`content/docs` page); `git diff --stat 99edfd0 origin/main --
.claude/skills/pm-dispatch/ .claude/agents/os-dev.md AGENTS.md` is empty
(15:15:22Z) — no governed file moved; the merge-base is still
`99edfd008e`.

**P5 — holds.** 〈模板与表〉 :799–:811 (read 14:57:13Z) carries `Claim: PM
loop round N`, `Branch: …`, `Clause-②: yes | no` each on its own line at
column 0. The template is untouched.

## Gates — this tree, `4c91f60ef2`

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths; changeset from merge-base
`99edfd008`: committed 1, working tree 0, untracked 0 —
`.claude/skills/pm-dispatch/SKILL.md`) derived **16** commands. Each run
in the foreground, exit captured by redirect-then-`$?`:

| command | exit |
|:--|--:|
| `node scripts/check-closing-keyword-parity.mjs` | 0 |
| `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 |
| `node scripts/check-comment-mask-corpus.mjs` | 0 |
| `node scripts/pm/check-governed-queue-guard.mjs --self-test` | 0 |
| `pnpm --filter @objectstack/lint run check:doc-formula-expressions` |
3 → 0 (see below) |
| `pnpm check:agent-test-spelling` | 0 |
| `pnpm check:doc-authoring` | 0 |
| `pnpm check:driver-memory-census` | 0 |
| `pnpm check:nul-bytes` | 0 |
| `pnpm check:pm-governed-merges` | 0 |
| `pnpm check:pm-governed-prose` | 0 |
| `pnpm check:pm-skill-id-lint` | 0 |
| `pnpm check:pm-skill-ratchet` | 0 |
| `pnpm check:refd-timer-probe` | 0 |
| `pnpm check:skill-frame-sync` | 0 |
| `pnpm check:watch-hint-literal` | 0 |

`check:doc-formula-expressions` first exited **3** — 「PREREQUISITE NOT
MET — the workspace package `@objectstack/formula` is not built …
Nothing was measured」 — not a reading. Built `@objectstack/formula` and
`@objectstack/lint` under `scripts/pm/os-verify-lock.sh` (「VERDICT
command-exit 0 · held the lock 219s (3m39s) · waited 0s」), rerun →
**exit 0** at 15:13:30Z.

`--ran` reconciliation (15:15:20Z): 「Run reconciliation — 16 derived, 16
run, 0 NOT-MEASURED, 0 UNRUN.」 … 「✓ dispatch-gates --ran: 16 derived
famil(ies) accounted for — 16 run, 0 NOT-MEASURED (a DERIVED zero — all
16 recorded an exit code and none of them is 3).」

Post-edit ratchet (`pnpm check:pm-skill-ratchet` exit 0, 15:08:39Z):
「.claude/skills/pm-dispatch/SKILL.md is 812 lines (ceiling 812; headroom
0)」, 「widest table row is 342 bytes (pin 342; headroom 0)」. `pnpm
check:skill-frame-sync` exit 0: 「the one declared copy of the decision
frame is internally coherent」. `pnpm check:pm-skill-id-lint` exit 0: 「27
file(s) clean」 — the new lines cite no card number. `pnpm
check:nul-bytes` exit 0; the control-character self-scan of SKILL.md →
no hits.

Not derived, not run: `pnpm lint` (repo-wide eslint, CI-owned; markdown
is outside its population) and `docs:check-links`. Not run: `pnpm test`
/ `pnpm typecheck` — the diff touches no package.

Changeset: none. `.claude/**` publishes nothing (fast lane: no `files[]`
of any released package ships it). No label is written from this
container.

## 维护者速读(草稿)

- 改了什么/为什么改:认领评论里 `Clause-②:`(连同
`Claim:`、`Branch:`)必须各自独占一行、写在行首,因为条款②入队闸门只读认领评论、只按行首读键——写进派发令的句子里或另发一条评论,闸门都读不到,卡就不可读。模板不变、脚本不变,只把规则层这三行改成
3→3(812/812 不动)。
- 风险与代价(含回滚)/席位意见/你要做的:零运行时影响,纯规则文本,回滚即 revert 本 PR 的一个
commit;席位意见:(留空,席位定稿成评论);你要做的:授权审批后由席位落地(裁决 C)。

## Acceptance notes

- Governed rules layer: this PR is a DRAFT and stays one. Ruling C
(objectstack-ai#17971) 「C. approve 后不管后续改动都由席位落地:」 governs the landing — the seat does
the four-piece after an authorized approval; nothing here is readied,
enqueued, auto-merged, approved or labelled from the dev container.
- P2's literal claim (the checker does not read `Branch:`) was falsified
by the tree (above) and reported as a mechanism-assumption correction;
the ruling's direction was kept and the "why" written so it is true of
all three keys.
- P1's predicted grep count was 0; measured 5 — none a rule on the claim
form (each hit read above).
- noted, not filed: `references/platform-readings.md` :350–:351 already
carries the checker-axis reading the new :473 agrees with; no drift,
nothing to file. 承接者:无.
- objectstack-ai#17497 (same file) stays queued behind this PR; objectstack-ai#17098 remains open
and its reader tolerance is untouched.
- Line-count payment: the 3→3 replacement pays its density inside the
same subsection by merging old :473 and :474 — no re-wrap, no line
elsewhere touched.

Clause-②: no

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

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant