Skip to content

fix(packages): a protocol-incompatible install answers 422 with its structured diagnostic - #21760

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21727-protocol-incompatible-4xx
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21727-protocol-incompatible-4xx

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21727
Clause-②: yes

Contract statement

  • Code: OS_PROTOCOL_INCOMPATIBLE. It stays registered under @objectstack/metadata-core, and its ledger row now states 422.
  • Status: 422, declared by ProtocolIncompatibleError as both status and statusCode. It used to be the 500 fallback.
  • Door: POST /api/v1/packages, in the install branch of packages/runtime/src/domains/packages.ts. Both arms answer: the composed protocol primitive and the no-protocol-service fallback. Measured, this is the only HTTP door that reaches assertProtocolCompat (H3 on the card).
  • Wire: error.code is 'OS_PROTOCOL_INCOMPATIBLE', error.httpStatus is 422, and error.message is unchanged. error.details is exactly { requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }. The door hands deps.error six members, code plus those five, which is the ruling's six. The shared envelope builder (error-envelope.ts) promotes code into error.code, so details carries five on the wire. ADR-0112 D5 retires error.details.code as a code carrier, and the FlowActionRefusal exit in domains/actions.ts behaves the same way.
  • Unchanged: resolveThrownHttpError and @objectstack/types, whose closed details list is the maintainer's A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585 ruling; the range check itself; what the composed door accepts.

Why 422

The manifest is well-formed, but it declares a range this runtime can never satisfy, and the remedy is a body change (objectstack migrate meta --from N). SchemaValidationError (METADATA_SCHEMA_INVALID) in the same package maps to 422. The ledger keeps 409 for refusals that come from environment state (its FLOW_CONVERSION_CONFLICT row), where resubmitting the same body could succeed later. Triage ruled 422 in 5980718772.

Mechanism

  • @objectstack/metadata-core: ProtocolIncompatibleError declares status and statusCode 422, and redeclares code as the literal 'OS_PROTOCOL_INCOMPATIBLE' (always equal to diagnostic.code). It is stamped with a Symbol.for brand, and isProtocolIncompatibleError(e) is exported. The brand holds across the ESM and CJS builds and across src and dist, where instanceof would not. A lookalike that copies the field names is not recognised. This is the A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585 FlowActionRefusal idiom.
  • The door: the install branch asks isProtocolIncompatibleError ahead of its generic catch, and answers through protocolIncompatibleAnswer: the error's declared status, its own message, and the five named fields. ⛔ It is not a spread of the diagnostic, so packageId, runtimeMajor and any member added later stay off the wire. Every other throw rethrows to the unchanged generic catch.
  • @objectstack/spec: a comment on the ledger row states 422 and the door. The comment ships in dist/ and src/**/*.zod.ts (measured), so the changeset carries a minor for spec.

Riding this PR, per triage 5980718772

  • The no-protocol-service fallback runs the same handshake. With no protocol service composed, the door used to call registry.installPackage(manifest, settings) with no check, which installed a ^16 manifest that the composed door refuses. It now calls assertProtocolCompat(manifest) first, with the same default warn hook as the primitive, and the same recognition answers it. A pin covers it.
  • check:error-status-conformance now sees this producer. Before this PR the class's code arrived only through super(), and the gate's R1 rule reads a readonly code literal. Measured: injecting readonly status = 422 alone left the gate output byte-identical. With the literal code, the scope line moves from 403 to 404 producer sites and from 85 to 86 derived ledger codes, and --report names OS_PROTOCOL_INCOMPATIBLE among the codes derived but not reconciled (no scanned docs page publishes a status for it). The gate script is unchanged; only the producer moved.

Pins (ADR-0112: each refusal asserts code and status)

The protocol primitive and the registry are real (ObjectStackProtocolImplementation over a SchemaRegistry) behind HttpDispatcher, in packages/runtime/src/domains/packages-install-protocol-incompatible.test.ts:

  1. The install answers 422 OS_PROTOCOL_INCOMPATIBLE. error.details holds exactly the five keys, each value equals what the prose states (targets protocol RANGE (SOURCE), this runtime is protocol VERSION., and the message ends with Run: MIGRATE_COMMAND), and migrateCommand ends --from TARGET_MAJOR. The envelope parses against ApiErrorSchema.
  2. The package is still not installed: GET /api/v1/packages/com.example.qaold answers 404 RESOURCE_NOT_FOUND, and the registry has no row.
  3. (Replaced, per triage.) AppPlugin.init throws the same ProtocolIncompatibleError, recognised by the brand and carrying status and statusCode 422 (packages/runtime/src/app-plugin.test.ts).

Also pinned: the fallback refusal (same 422 and details, nothing written), lit controls on both arms (^17 installs, then 201 and a read-back of 200), and a lookalike throw that is served by the generic catch (500, no details). protocol-handshake.test.ts pins the declared status, the literal code and the brand predicate.

Reverse verification

From committed b5bdd1be86, under a trap restore, both mutations went through scripts/ablation-replace.mjs. Each anchor hit once, and the blob changed (door 2fc34268 → 838e7074, error b15e059f → edaaa53b).

  • The door recognition was neutered, and the error's readonly status / readonly statusCode lines were deleted.
  • metadata-core was rebuilt, and ablation-dist-preflight confirmed this.statusCode = 422; absent from all 12 built files.
  • Result: Tests 3 failed | 39 passed (42). The failing set was exactly pin 1, the fallback refusal and pin 3, which failed on expected 500 to be 422 and the missing 422 on the thrown value. Pin 2, the controls and the lookalike stayed green.
  • The restore used git checkout HEAD -- on both absolute paths. Both blobs equal HEAD (2fc34268, b15e059f), and git diff HEAD is empty. After a rebuild, preflight confirmed the marker present again and the tree clean, and the rerun was Tests 42 passed (42).

Measured, not fixed

marketplace-install-local runs no protocol handshake. Through the plugin's real POST /api/v1/marketplace/install-local handler, a manifest with engines.protocol: "^16" answered 200 success: true. It was registered (manifest.register), written to the ledger (com.example.qaold.json) and schema-synced, with no protocol warning, the same as the ^17 control. This is reported in the os-dev report on the card as an out-of-scope finding. ⛔ It is not fixed here.

Tests and gates, at 2de914e50e (after merging origin/main at ea7ff394b6)

  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2: Test Files 324 passed, Tests 4617 passed and 19 skipped.
  • pnpm --filter @objectstack/metadata-core run test: 17 files, 328 tests passed.
  • pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts: 21 passed (run at b5bdd1be86).
  • typecheck: @objectstack/runtime (tsc --noEmit plus check:test-typecheck OK) and @objectstack/metadata-core, both exit 0.
  • pnpm --filter @objectstack/spec check:generated: all 15 generated artifacts up to date.
  • node scripts/pm/dispatch-gates.mjs --ran reports 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN, every row with its exit code recorded.
    • Also run: the 52 artifact-roster commands, and the four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors), all exit 0.
    • Three roster gates need a pull request's context (check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths). They are run against this PR after it opens.
    • check:dual-build-cjs-loads and check:published-readme-exports first answered PREREQUISITE NOT MET. After a full turbo run build, they were rerun green: 106 require entry points across 66 packages load, and 101 published documents check out.
  • ESLint (a declared narrowing; pnpm lint is CI's run):
    • Command: eslint --no-inline-config --format json on the six changed TS files, which gave files 6, errors 0, warnings 0.
    • Population: every changed file is under the config's **/*.{ts,…} block.
    • Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, stated at its own :326-:328), so this diff cannot move any untouched file's verdict.

Acceptance notes

  • Changeset: .changeset/21727-protocol-incompatible-422.md bumps @objectstack/metadata-core, @objectstack/runtime and @objectstack/spec by minor. It carries Clause-②: yes and an adr-0087 not-required (no-migration-prescription) marker. The gate reads the changeset as non-breaking.
  • The fallback: the no-protocol-service arm now refuses a manifest it used to install. I read that as restoring ADR-0087 D1's installer contract, which the composed door already kept, not as narrowing a published accept set, so the line stays Clause-②: yes as triage ruled. If the review reads it as a narrowing, the line becomes yes (narrowing), and the marker above already answers the disposition.
  • Status spelling: SchemaValidationError states its 422 only in a docblock ("Maps to HTTP 422") and declares no status member. This class matches it in value, and its declared spelling follows the two-spelling convention resolveThrownHttpError reads.
  • Gate reach: check:error-status-conformance still skips, silently, any class that declares a status but whose code arrives only through super(). Here the producer was made visible. Making the gate report that shape as unresolved would be its own change.
  • Consumers (H5): none branch on 500 for this code. Zero hits in the CLI, the SDK, MCP and objectui main b508ac5. The SDK surfaces error.details generically, so err.details.migrateCommand reaches SDK callers with no client change.

Generated by Claude Code

claude added 3 commits October 4, 2026 14:35
…tructured diagnostic

ProtocolIncompatibleError declares 422 (status and statusCode) and a
literal code, and carries a Symbol.for brand with an
isProtocolIncompatibleError predicate. The install branch of
POST /api/v1/packages recognises it ahead of its generic catch and
answers the declared status with requiredRange, rangeSource,
protocolVersion, targetMajor and migrateCommand in error.details. The
no-protocol-service fallback runs the same handshake. The ledger row
for OS_PROTOCOL_INCOMPATIBLE states 422.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 4, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-core, @objectstack/runtime, @objectstack/spec, touching 11 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/permissions/field-level-security.mdx (via statusCode (symbol, a field of class ProtocolIncompatibleError))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/permissions/sharing-rules.mdx (via /sharing/rules/:idOrName/evaluate (route, bridged from symbol statusCode — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))

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

  • content/docs/releases/v12.mdx (via ProtocolIncompatibleError (symbol, a top-level class), OS_PROTOCOL_INCOMPATIBLE (literal, a string literal in ProtocolIncompatibleError))
  • content/docs/releases/v15.mdx (via OS_PROTOCOL_INCOMPATIBLE (literal, a string literal in ProtocolIncompatibleError))
  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-5.mdx (via OS_PROTOCOL_INCOMPATIBLE (literal, a string literal in ProtocolIncompatibleError), /api/v1/packages (route, a path literal in a comment in ERROR_CODE_LEDGER; a path literal in a comment on a changed line))

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3eb38aeb125c838d54ba92aab11bfafde53b653f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8f37d555951f10782cf14beb5b768a00f60df906 — the merge of head 2de914e50e694d9715e07cea8570cc7aefe94a6a into base 3eb38aeb125c838d54ba92aab11bfafde53b653f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8f37d555951f10782cf14beb5b768a00f60df906 && git checkout 8f37d555951f10782cf14beb5b768a00f60df906
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3eb38aeb125c838d54ba92aab11bfafde53b653f 2de914e50e694d9715e07cea8570cc7aefe94a6a && git checkout -B drift-repro 3eb38aeb125c838d54ba92aab11bfafde53b653f && git merge --no-ff 2de914e50e694d9715e07cea8570cc7aefe94a6a

node scripts/docs-audit/affected-docs.mjs --json 3eb38aeb125c838d54ba92aab11bfafde53b653f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3eb38aeb125c838d54ba92aab11bfafde53b653f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review) — PR #21760 at head 2de914e50e · contract review owed before landing

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T16:10Z. The os-dev reports are on #21727: 5980137895 (needs_decision, first leg), then this resume. Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.

    • The first lines are Fixes #21727 and Clause-②: yes.
    • The closing-keyword scan finds #21727 only.
  • Scope: 7 files, +420/-5:

    • metadata-core's handshake error and its test;
    • the runtime packages door, the new pin file and the AppPlugin test;
    • the spec ledger row;
    • the changeset.

    NOT governed. A local git merge-tree against origin/main is clean.

  • The diff, read: triage's route B (5980718772), as ruled.

    • ProtocolIncompatibleError declares status and statusCode 422 and a literal code. It carries a Symbol.for brand, read by the new isProtocolIncompatibleError, following A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585's FlowActionRefusal idiom across the dual ESM and CJS builds.
    • The install branch of POST /api/v1/packages catches it ahead of the generic catch and answers deps.error(message, 422, { code, requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }). Anything else is rethrown.
    • The shared envelope promotes code into error.code (ADR-0112 D5), so error.details carries the five fields. The PR body states this.
    • ⛔ resolveThrownHttpError is untouched.
    • The no-protocol-service fallback now calls assertProtocolCompat before registry.installPackage.
    • The ledger row states 422 and the carrying door.
  • Pins:

    • Pin 1: 422, the code, ApiErrorSchema, and details keys exactly the five, each equal to the prose.
    • Pin 2: GET answers 404 with no registry row.
    • Pin 3, replaced as ruled: AppPlugin.init throws the branded error with 422.
    • Also pinned: the fallback refusal, lit controls on both arms, and a lookalike served by the generic catch with 500 and no details.
  • Reverse verification: the door recognition and the declared status were ablated, with a dist preflight.

    • Exactly pin 1, the fallback and pin 3 went red. Pin 2, the controls and the lookalike stayed green.
    • The restore was proved by blob equality and an empty git diff HEAD.
  • check:error-status-conformance now derives this producer: 404 producer sites (from 403) and 86 codes (from 85), with OS_PROTOCOL_INCOMPATIBLE named. It is made visible on the producer side, through the literal code. The gate script is unchanged, and its super()-only blind spot is an Acceptance note.

  • The dev's open question — answered: A, keep Clause-②: yes. The fallback refuses what the published contract (ADR-0087 D1) and the composed door already refused, and triage ruled that the line stands. The contract review rules on it as boundary flag ③.

  • Changeset, checked sentence by sentence:

    • minor for metadata-core, runtime and spec, with Clause-②: yes.
    • The POST /api/v1/packages bullet matches pins 1 and 2 and the Run: / migrateCommand equality.
    • The fallback bullet matches its pin.
    • The metadata-core bullet (the 422 declaration, the literal code, the brand, the new export, and AppPlugin reading 422) matches pin 3.
    • The spec bullet (the row states its status, and the vocabulary is unchanged) matches.
    • The migration note: 0 consumers in the repository, the SDK and objectui main.
    • adr-0087: not-required (no-migration-prescription) with its facts.
  • Evidence:

    • runtime: 4617 tests pass. metadata-core: 328. The spec ledger test: 21.
    • Both typechecks are green, with the runtime ledger held.
    • spec check:generated reports the generated artifacts up to date.
  • Gates:

    • dispatch-gates --ran: 89 of 89 exit 0.
    • The roster's 52 families exit 0, and so do the 3 PR-context guards with this PR's context.
    • The four symbol-anchor sweeps exit 0.
  • Labels: needs:contract-review is hung on this PR in the same act. The seat runs the review at its tier once CI on this head has concluded, posts the record, and only then lands.

  • CI: read at landing.

Out-of-scope finding, filed by the seat: #21762. marketplace-install-local installs a ^16 manifest with 200 success, while this door now refuses it with 422. That is ADR-0087 D1's "the package installer", measured through the plugin's real route handler.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2de914e50e694d9715e07cea8570cc7aefe94a6a
Local-runs: none

Inputs: card #21727 (body and comments 5979744340, 5979944377, 5980137895, 5980158580, 5980718772, 5981902065), PR #21760 (body, the 7-file list, the net diff against main at the head, merge-base ea7ff394b6), and the 39 check-runs on the head. No dispatch order and no seat conclusion was read. Read-only: no worktree, no build, no test, no gate run. Every line number below is read at the head.

Check-runs on the head

39 check-runs, all completed. None concluded failure. The seven required contexts are all success: Lint & Repo Gates (111474425782), TypeScript Type Check (111477540942), Test Core (111481328655), Dogfood Regression Gate (111476388132), Build Core (111474497007), Temporal Conformance (live PG + MySQL) (111474496972), Governed Surface Queue Guard (111474425831). Check Changeset ran twice, both success (111474425182, 111476067493). The skipped runs are path-filtered or opt-in jobs (Console Pin Gate, Build Docs, Packed-tarball smoke, and the second-event Auto Label and Check PR Size). None of the seven files is a governed surface; the queue guard's success agrees.

① Derived judgments

Public surface of @objectstack/metadata-core, published through packages/metadata-core/src/index.ts:16 (export * from './protocol-handshake.js'):

  1. ProtocolIncompatibleError declares status = 422 and statusCode = 422 (packages/metadata-core/src/protocol-handshake.ts:119-120). RIGHT. These are the two spellings resolveThrownHttpError reads, status first (packages/types/src/thrown-http-error.ts:215-217). 422 is the ruled value (5980718772 item 2). The ledger reserves 409 for refusals that come from environment state (packages/spec/src/api/error-code-ledger.zod.ts:592-594); this refusal comes from the body's own range, and the remedy is a body change. Additive. Pinned at protocol-handshake.test.ts:214-225.
  2. code is redeclared as the literal 'OS_PROTOCOL_INCOMPATIBLE' (protocol-handshake.ts:118). RIGHT. It narrows the base MetadataError.code: string (packages/metadata-core/src/errors.ts:12) to a literal on a readonly member, which every reader accepts. The value equals diagnostic.code by construction (the diagnostic literal in checkProtocolCompat). It makes the class visible to check:error-status-conformance's R1 pair reading (scripts/check-error-status-conformance.mjs:27-28, 107-108).
  3. A Symbol.for brand (protocol-handshake.ts:81, stamped at :124) and the new export isProtocolIncompatibleError (:135). RIGHT. This is the FlowActionRefusal idiom (packages/runtime/src/action-execution.ts:775, 844). A lookalike that copies the field names is refused (protocol-handshake.test.ts:228-246). A new exported symbol on an index is a widening and takes at least minor.

Wire of POST /api/v1/packages. This is the one production door: packages/runtime/src/dispatcher-plugin.ts:1317-1324 routes every package route through dispatch() to handlePackagesRequest, and the REST registrar has mounted only POST /packages/publish since #14503 (packages/rest/src/package-routes.ts:30, 107-108).

  1. The refusal moves from the 500 fallback to the declared 422, with error.code OS_PROTOCOL_INCOMPATIBLE, error.httpStatus 422 and the message unchanged (packages/runtime/src/domains/packages.ts:607-617, 1326-1327). RIGHT. A refusal of a caller-authored body is a client fault, which is the card's second defect. No accepted input moves: the manifest was refused before and is refused now. No consumer branches on 500 (5981902065, H5). Not a narrowing.
  2. error.details carries exactly requiredRange, rangeSource, protocolVersion, targetMajor and migrateCommand (packages.ts:609-616; the closed key set is pinned at packages/runtime/src/domains/packages-install-protocol-incompatible.test.ts:87, 135). RIGHT. The door hands six members and the envelope builder promotes the string code into error.code (packages/runtime/src/error-envelope.ts:100-109), so five land in details. ADR-0112 D5 retires error.details.code as a code carrier (docs/adr/0112-error-code-vocabulary-and-ledger.md:154). This puts ADR-0087 D1's structured diagnostic on the wire (docs/adr/0087-metadata-protocol-upgrade-contract.md:110-116) and satisfies QA clause A2 of platform-core.manifest-install-contract (docs/qa/platform-checklist/areas/platform-core.json, the "STRUCTURED diagnostic" clause, which names these five fields). New keys on a published payload: a widening.
  3. resolveThrownHttpError and @objectstack/types are untouched. RIGHT. The closed details list is the A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585 ruling recorded at action-execution.ts:794-800; widening it is the rejected Option A. The door recognises the brand ahead of its generic catch and rethrows every other throw unchanged (packages.ts:1326-1329). Triage lifted the "no packages-door special case" fence for this one route (5980718772 item 1).
  4. The no-protocol-service fallback runs assertProtocolCompat(manifest) before registry.installPackage (packages.ts:1314-1315). RIGHT. The call uses the same default arguments as the protocol primitive's own call (packages/metadata-protocol/src/protocol.ts:26136, also assertProtocolCompat(manifest)), so both arms warn and refuse identically. Pinned with a lit control at packages-install-protocol-incompatible.test.ts:190-203. Whether this narrows a published accept set is judged in ② and ③: it does not.
  5. The lookalike control: a plain Error carrying the same code and a diagnostic is served by the generic catch as 500 with no details (packages-install-protocol-incompatible.test.ts:173-187). RIGHT. It keeps the structured channel closed to impersonation.

The boot seam and the spec ledger:

  1. AppPlugin.init (packages/runtime/src/app-plugin.ts:409) throws the same class, now carrying 422 (packages/runtime/src/app-plugin.test.ts:503-525). RIGHT. Triage replaced pin 3 with this thrown-value pin (5980718772 item 4). Nothing on the boot path reads status, so boot behaviour is unchanged.
  2. @objectstack/spec: a comment on the OS_PROTOCOL_INCOMPATIBLE ledger row states 422 and the door (error-code-ledger.zod.ts:632-643). RIGHT as a statement. The vocabulary is unchanged, so no accept set moves. The spec generated-artifact gates ran inside the green TypeScript Type Check run.

Nothing in the diff relaxes an accept set. Every public-surface change is additive.

② Semver level

  • The changeset .changeset/21727-protocol-incompatible-422.md grades @objectstack/metadata-core, @objectstack/runtime and @objectstack/spec minor (:2-4). RIGHT for the PR. A new exported symbol on metadata-core's index (①3) and new keys on a published payload (①5) each take at least minor under the WHICH LEVEL rule (.github/workflows/pr-automation.yml:754-758). major is not in play. All three packages sit in the one fixed group (.changeset/config.json), so the release level is the group's highest bump regardless. Noted, not a defect: spec's own act is a comment, which the rule grades patch on its own; under the fixed group the higher grade has no release effect, and the entry's sentence is true.
  • Clause-②: yes with no arm, in the PR body (its line 2) and in the changeset (:9). RIGHT. The question is 「本卡放宽接受集或扩大公开面吗」 (scripts/pm/clause2-line.mjs:70). The public surface widens (①3, ①5), so yes. The (narrowing) arm (clause2-line.mjs:93) is not owed. The route's accept set is published by ADR-0087 D1, which says the package installer refuses an excluding range before loading (0087:110-116), and the composed arm already refused it. The fallback arm's prior acceptance was a declared-but-unenforced gap on the same route. Closing that gap is enforcement of the published contract (Prime Directive chore: version packages #10), not a narrowing of a published accept set. No admitted value moves.
  • The ADR-0087 disposition not-required (no-migration-prescription) (:18). RIGHT. The spelling is in CATEGORIES (scripts/check-adr-0087-registration.mjs:494-497). The changeset body carries no FROM → TO rewrite, so the category's own refusal does not fire. The gate read the changeset as non-breaking (5981902065, gates), which matches the judgment above. The marker stands ready should a later reader grade it otherwise.
  • The check-changeset-no-major LEVEL axis is satisfied: yes is declared and the moved packages are graded minor. Check Changeset is success twice.

③ Boundary flags

Open question (5981902065, the one entry): the Clause-② arm for the fallback rider. ANSWERED: option A, Clause-②: yes with no arm, on the grounds in ②. The marker already answers the disposition, so the changeset does not change.

Deviations (5981902065):

  1. Six members handed, five on the wire. ACCEPTED. splitSemanticCode promotes the string code (error-envelope.ts:100-109); the test pins exactly five keys (:135). The ruling's six (5980718772 item 1) are met at the door's call.
  2. SchemaValidationError states 422 only in a docblock. ACCEPTED. The value matches; the two-spelling declaration matches the resolver's read order (thrown-http-error.ts:215-217).
  3. A new export on @objectstack/metadata-core. ACCEPTED. It is the ruled idiom and the changeset grades it minor.
  4. The conformance gate script is unchanged; the producer was made visible. ACCEPTED. R1 reads a readonly code beside a readonly status (check-error-status-conformance.mjs:107-108); CI is green. The gate-side blind spot is escalated below.
  5. The spec ledger test was run at b5bdd1be86 and rerun at the head. ACCEPTED. The check-runs on the head are the verdicts.
  6. Two os-dev-reports on the card. ACCEPTED, procedural.
  7. Worktree removed. ACCEPTED, procedural.

Out-of-scope findings (5981902065):

  1. POST /api/v1/marketplace/install-local runs no protocol handshake; a ^16 manifest installs, measured. ESCALATED to the owning seat. It is a reproducible contract violation of ADR-0087 D1 with measured reach, which Prime Directive chore: version packages #10 says takes a card. Triage ruled it is not fixed here (5980718772). It does not block this PR.
  2. check:error-status-conformance silently skips a class whose code arrives only through super(). ESCALATED to the seat as an observation, carrier none. Not blocking.

Also observed, pre-existing and outside this card: ADR-0087 D1 names "the blocking surfaces" among the diagnostic's members (0087:115); ProtocolIncompatibleDiagnostic has never carried such a member (protocol-handshake.ts:53-72), and QA clause A2 names only the five fields this PR carries. Not judged here.

Noted, not blocking: the reverse-verification run combined the door neutering with the status deletion in one ablation. Pin 1's Object.keys(error.details) assertion (packages-install-protocol-incompatible.test.ts:135) fails on the door neutering alone, so the recognition is load-bearing independently of the status.

Implemented-by: claude/issue-21727-protocol-incompatible-4xx
Reviewed-by: session_017ErfyP2Rx7XWHJA27QjyUi

VERDICT: PASS

Adopted by domain:engine#1 (session_017ErfyP2Rx7XWHJA27QjyUi) at 2026-10-05T00:11Z as the record of head 2de914e50e, the current head. needs:contract-review is removed in this act. Out-of-scope finding 1 is already filed as #21762.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 00:15
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 00:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit e83c9f6 Oct 5, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21727-protocol-incompatible-4xx branch October 5, 2026 00:47
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/m tests tooling

Projects

None yet

2 participants