Repository navigation
fix(packages): a protocol-incompatible install answers 422 with its structured diagnostic - #21760
Conversation
…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>
…diagnostic Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…otocol-incompatible-4xx
📓 Docs Drift CheckThis PR changes 3 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
ACCEPT (seat review) — PR #21760 at head
|
Contract reviewServed-tier: Inputs: card #21727 (body and comments 5979744340, 5979944377, 5980137895, 5980158580, 5980718772, 5981902065), PR #21760 (body, the 7-file list, the net diff against Check-runs on the head39 check-runs, all ① Derived judgmentsPublic surface of
Wire of
The boot seam and the spec ledger:
Nothing in the diff relaxes an accept set. Every public-surface change is additive. ② Semver level
③ Boundary flagsOpen question (5981902065, the one entry): the Clause-② arm for the fallback rider. ANSWERED: option A, Deviations (5981902065):
Out-of-scope findings (5981902065):
Also observed, pre-existing and outside this card: ADR-0087 D1 names "the blocking surfaces" among the diagnostic's members ( Noted, not blocking: the reverse-verification run combined the door neutering with the status deletion in one ablation. Pin 1's Implemented-by: VERDICT: PASS Adopted by Generated by Claude Code |
Fixes #21727
Clause-②: yes
Contract statement
OS_PROTOCOL_INCOMPATIBLE. It stays registered under@objectstack/metadata-core, and its ledger row now states 422.422, declared byProtocolIncompatibleErroras bothstatusandstatusCode. It used to be the500fallback.POST /api/v1/packages, in the install branch ofpackages/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 reachesassertProtocolCompat(H3 on the card).error.codeis'OS_PROTOCOL_INCOMPATIBLE',error.httpStatusis422, anderror.messageis unchanged.error.detailsis exactly{ requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }. The door handsdeps.errorsix members,codeplus those five, which is the ruling's six. The shared envelope builder (error-envelope.ts) promotescodeintoerror.code, sodetailscarries five on the wire. ADR-0112 D5 retireserror.details.codeas a code carrier, and theFlowActionRefusalexit indomains/actions.tsbehaves the same way.resolveThrownHttpErrorand@objectstack/types, whose closeddetailslist is the maintainer's A flow ACTION that fails cannot carry the author'serrorMessageor the runsummary— the/actionsdoor 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 (itsFLOW_CONVERSION_CONFLICTrow), where resubmitting the same body could succeed later. Triage ruled 422 in 5980718772.Mechanism
@objectstack/metadata-core:ProtocolIncompatibleErrordeclaresstatusandstatusCode422, and redeclarescodeas the literal'OS_PROTOCOL_INCOMPATIBLE'(always equal todiagnostic.code). It is stamped with aSymbol.forbrand, andisProtocolIncompatibleError(e)is exported. The brand holds across the ESM and CJS builds and acrosssrcanddist, whereinstanceofwould not. A lookalike that copies the field names is not recognised. This is the A flow ACTION that fails cannot carry the author'serrorMessageor the runsummary— the/actionsdoor has no channel for them #9585FlowActionRefusalidiom.isProtocolIncompatibleErrorahead of its generic catch, and answers throughprotocolIncompatibleAnswer: the error's declared status, its own message, and the five named fields. ⛔ It is not a spread of the diagnostic, sopackageId,runtimeMajorand 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 indist/andsrc/**/*.zod.ts(measured), so the changeset carries aminorfor spec.Riding this PR, per triage 5980718772
protocolservice composed, the door used to callregistry.installPackage(manifest, settings)with no check, which installed a^16manifest that the composed door refuses. It now callsassertProtocolCompat(manifest)first, with the same default warn hook as the primitive, and the same recognition answers it. A pin covers it.check:error-status-conformancenow sees this producer. Before this PR the class's code arrived only throughsuper(), and the gate's R1 rule reads areadonly codeliteral. Measured: injectingreadonly status = 422alone 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--reportnamesOS_PROTOCOL_INCOMPATIBLEamong 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
codeandstatus)The protocol primitive and the registry are real (
ObjectStackProtocolImplementationover aSchemaRegistry) behindHttpDispatcher, inpackages/runtime/src/domains/packages-install-protocol-incompatible.test.ts:422 OS_PROTOCOL_INCOMPATIBLE.error.detailsholds exactly the five keys, each value equals what the prose states (targets protocol RANGE (SOURCE),this runtime is protocol VERSION., and the message ends withRun: MIGRATE_COMMAND), andmigrateCommandends--from TARGET_MAJOR. The envelope parses againstApiErrorSchema.GET /api/v1/packages/com.example.qaoldanswers404 RESOURCE_NOT_FOUND, and the registry has no row.AppPlugin.initthrows the sameProtocolIncompatibleError, recognised by the brand and carryingstatusandstatusCode422 (packages/runtime/src/app-plugin.test.ts).Also pinned: the fallback refusal (same 422 and details, nothing written), lit controls on both arms (
^17installs, then 201 and a read-back of 200), and a lookalike throw that is served by the generic catch (500, nodetails).protocol-handshake.test.tspins the declared status, the literal code and the brand predicate.Reverse verification
From committed
b5bdd1be86, under atraprestore, both mutations went throughscripts/ablation-replace.mjs. Each anchor hit once, and the blob changed (door2fc34268→838e7074, errorb15e059f→edaaa53b).readonly status/readonly statusCodelines were deleted.metadata-corewas rebuilt, andablation-dist-preflightconfirmedthis.statusCode = 422;absent from all 12 built files.Tests 3 failed | 39 passed (42). The failing set was exactly pin 1, the fallback refusal and pin 3, which failed onexpected 500 to be 422and the missing 422 on the thrown value. Pin 2, the controls and the lookalike stayed green.git checkout HEAD --on both absolute paths. Both blobs equal HEAD (2fc34268,b15e059f), andgit diff HEADis empty. After a rebuild, preflight confirmed the marker present again and the tree clean, and the rerun wasTests 42 passed (42).Measured, not fixed
marketplace-install-localruns no protocol handshake. Through the plugin's realPOST /api/v1/marketplace/install-localhandler, a manifest withengines.protocol: "^16"answered200 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^17control. 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 mergingorigin/mainatea7ff394b6)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 atb5bdd1be86).typecheck:@objectstack/runtime(tsc --noEmitpluscheck:test-typecheckOK) 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 --ranreports89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN, every row with its exit code recorded.check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchors,check:adr-anchors), all exit 0.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-loadsandcheck:published-readme-exportsfirst answered PREREQUISITE NOT MET. After a fullturbo run build, they were rerun green: 106 require entry points across 66 packages load, and 101 published documents check out.pnpm lintis CI's run):eslint --no-inline-config --format jsonon the six changed TS files, which gave files 6, errors 0, warnings 0.**/*.{ts,…}block.eslint.config.mjsnever enables type-aware linting (noparserOptions.project, stated at its own:326-:328), so this diff cannot move any untouched file's verdict.Acceptance notes
.changeset/21727-protocol-incompatible-422.mdbumps@objectstack/metadata-core,@objectstack/runtimeand@objectstack/specbyminor. It carriesClause-②: yesand anadr-0087not-required (no-migration-prescription)marker. The gate reads the changeset as non-breaking.Clause-②: yesas triage ruled. If the review reads it as a narrowing, the line becomesyes (narrowing), and the marker above already answers the disposition.SchemaValidationErrorstates its 422 only in a docblock ("Maps to HTTP 422") and declares nostatusmember. This class matches it in value, and its declared spelling follows the two-spelling conventionresolveThrownHttpErrorreads.check:error-status-conformancestill skips, silently, any class that declares a status but whose code arrives only throughsuper(). Here the producer was made visible. Making the gate report that shape as unresolved would be its own change.500for this code. Zero hits in the CLI, the SDK, MCP and objectui mainb508ac5. The SDK surfaceserror.detailsgenerically, soerr.details.migrateCommandreaches SDK callers with no client change.Generated by Claude Code