Skip to content

fix(cloud-connection): install-local runs the ADR-0087 D1 protocol handshake and refuses with the packages door answer (422) - #21805

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21762-install-local-protocol-handshake
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21762-install-local-protocol-handshake

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21762

Clause-②: yes (widening)

What changes

POST /api/v1/marketplace/install-local now runs ADR-0087 D1's protocol handshake, and its kernel:ready rehydrate does too. On origin/main both loaded a package built for another protocol major.

  • Install door (packages/cloud-connection/src/marketplace-install-local-plugin.ts, step 1c). The route calls assertProtocolCompat right after the manifest id is parsed. That is before the unrunnable-code judgement, the collision check, the posture gate, manifest.register, the ledger write and syncSchemas. A refusal answers 422 OS_PROTOCOL_INCOMPATIBLE with the handshake's message and error.details: { requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }. The answer is the same on the inline-manifest branch and the cloud-snapshot branch. A missing or unreadable range is still admitted, and the handshake's warning goes to ctx.logger.warn.
  • Rehydrate. On kernel:ready, checkProtocolCompat judges each ledger entry before register. An incompatible entry is not loaded: nothing is registered, synced, bound or seeded for it. One error line names the code, the package, the handshake's message (which ends with the replay command) and the two remedies (install a compatible version, or DELETE). The boot continues, and the entry stays in the ledger. A missing or unreadable range rehydrates as before, with no new warning.
  • One shared answer (packages/metadata-core/src/protocol-handshake.ts). protocolIncompatibleAnswer(err: ProtocolIncompatibleError): ProtocolIncompatibleAnswer returns { status, code, message, details }. details is a closed shape with the five members named one by one. It sits beside ProtocolIncompatibleError and isProtocolIncompatibleError. The packages door's module-private protocolIncompatibleAnswer(deps, err) is deleted, and packages/runtime/src/domains/packages.ts now calls the shared helper. Both doors recognise the error with the shared brand predicate and shape it with the shared helper, so no second copy is left.
  • Dependency edge. @objectstack/cloud-connection now imports @objectstack/metadata-core from production source, so the package moves from devDependencies to dependencies (pnpm-lock.yaml: only that importer hunk). No new package enters the install closure, because runtime and core already depend on it.

Rulings applied (triage 5982323247 and its unlock 5986276908)

  • The install route calls the same assertProtocolCompat before anything is registered, written or synced.
  • The refusal answers through the same carrier as the packages door: 422, with the structured diagnostic in details. Shared, not copied.
  • The recogniser is one helper both doors call. It sits beside ProtocolIncompatibleError in metadata-core, the measured common ancestor of runtime and cloud-connection (see H4).
  • The rehydrate was measured and then pinned. On BASE it loaded the incompatible entry. Now it does not, logs loudly, and the boot continues. This is the behaviour the ruling expected.

Mechanism assumptions, measured at BASE e27a7c0c9e

  • H1, confirmed. git grep -E "assertProtocolCompat|checkProtocolCompat|OS_PROTOCOL_INCOMPATIBLE" over the plugin: 0 hits. The same grep hits runtime/src/domains/packages.ts and metadata-core/src/protocol-handshake.ts.
  • H2, confirmed. metadata-core's . entry has export * from './protocol-handshake.js', which already exported assertProtocolCompat, checkProtocolCompat, isProtocolIncompatibleError and ProtocolIncompatibleError.
  • H3, confirmed. protocolIncompatibleAnswer(deps, err) at packages.ts:607 was module-private and took DomainHandlerDeps.
  • H4, confirmed. cloud-connection listed metadata-core only in devDependencies. Its vitest.config.ts already aliases @objectstack/metadata-core to source, so the check:test-source-alias ledger does not move. Neither runtime nor core re-exports metadata-core, so a direct edge is the only import path. That path does not cross a layering gate: check:lean-entry-closure guards only @objectstack/objectql/core. check:undeclared-dep-imports is green with the edge.
  • H5, measured. For the card's manifest, the two doors give byte-identical status, error.code, error.message and error.details. The parity case below asserts this. The envelopes differ in one member: the dispatcher's builder adds error.httpStatus to every error it emits, and install-local's hand-built bodies have never carried it on any exit. Both parse as ApiErrorSchema. See the acceptance notes.
  • H6, measured with a throwaway probe on BASE (the plugin through start + kernel:ready, with a ledger entry engines.protocol: ^16). Registered ids: [marketplace-installed-ui, com.example.qaold]. syncSchemas calls: 1. logger.error: []. logger.warn: []. logger.info: rehydrated com.example.qaold@1.0.0. The install reproduced the card: 200, registered, ledger file written, 1 sync.

Clause-② reading: built declaration closure, before and after

The dist of metadata-core, runtime and cloud-connection was built at BASE and again at HEAD. The TypeScript checker walked each entry: the exported names, plus every declaration reachable through members, parameters, return types and heritage.

  • @objectstack/metadata-core .: grows by exactly two names. There are 169 exports before and 171 after. The two new ones are interface ProtocolIncompatibleAnswer { status: ProtocolIncompatibleError['status']; code: ProtocolIncompatibleError['code']; message: string; details: Pick of ProtocolIncompatibleDiagnostic over 'requiredRange' | 'rangeSource' | 'protocolVersion' | 'targetMajor' | 'migrateCommand' } and declare function protocolIncompatibleAnswer(err: ProtocolIncompatibleError): ProtocolIncompatibleAnswer. Every type reachable through them (ProtocolIncompatibleError, ProtocolIncompatibleDiagnostic, RangeSource) was already exported, and its declaration text is unchanged. The 24 external references are identical. Five SysMetadata*Object declarations hash differently only because the emitted field-type union prints its members in a different order ("user" | "code" becomes "code" | "user"). The member set is the same.
  • @objectstack/metadata-core ./testing: the closure is identical.
  • @objectstack/runtime .: index.d.ts and index.d.cts are byte-identical before and after (sha256 prefix cb442723451fa416). The 513 exports are unchanged.
  • @objectstack/cloud-connection .: the 40 exports are unchanged. MarketplaceInstallLocalPlugin's declaration gains one untyped private reportProtocolIncompatibleEntry; and doc text. The class already had private members, so its assignability does not move.
  • Verdict: yes (widening) holds, for metadata-core only. The door's accept set narrows, but it narrows back to a declared contract: ADR-0087 D1 checks "the package installer", and POST /api/v1/packages already refused the same manifest. I read that as outside Clause-② (execution-duties.md: 条款②只指已发布契约面,拉回已声明契约不触它). The seat should confirm this; I did not take the (narrowing) arm.
  • Changeset levels: metadata-core minor, cloud-connection patch, runtime patch. All three are in the fixed group.

metadata-core . exports after this change (order-insensitive; the two new names are ProtocolIncompatibleAnswer and protocolIncompatibleAnswer):

AUDIT_FIELD_DEFS, AUDIT_FIELD_GOVERNANCE, AnonymousFormIntakeCandidate, AnonymousFormIntakeUnavailable, ArtifactConversionNotice, ArtifactForwardConversionOptions, ArtifactForwardConversionResult, ArtifactForwardConversionVerdict, ArtifactReplayedRetirement, BOUND_FORM_FIELD_PREDICATE_ROOTS, BOUND_FORM_VIEW_PREDICATE_ROOTS, BranchError, CacheStats, ConflictError, DeleteOptions, DeleteResult, ENGINE_DELETE_DISPATCH_CASES, ENGINE_DELETE_REJECT_MESSAGE, ENGINE_FINDONE_PREDICATE_CASES, ENGINE_UPDATE_DISPATCH_CASES, ENGINE_UPDATE_ID_CONFLICT_CODE, ENGINE_UPDATE_ID_CONFLICT_STATUS, ENGINE_UPDATE_REJECT_MESSAGE, EngineDeleteDispatch, EngineDeleteDispatchCase, EngineDeleteDispatchInput, EngineFindOnePredicate, EngineFindOnePredicateCase, EngineFindOneQueryInput, EngineUpdateDispatch, EngineUpdateDispatchCase, EngineUpdateDispatchData, EngineUpdateDispatchInput, FormPredicateSurface, HistoryOptions, ITEM_KEY_DISCRIMINATORS, InMemoryRepository, InMemoryRepositoryOptions, InjectedColumnProvenance, LAYER_SOURCE, LayerConfig, LayeredRepository, LayeredRepositoryOptions, ListFilter, METADATA_AUTHORING_CAPABILITY, MetaRef, MetaRefSchema, MetaWriteCapabilityVerdict, MetaWriteOperation, MetadataCache, MetadataCacheOptions, MetadataError, MetadataEvent, MetadataEventSchema, MetadataItem, MetadataItemHeader, MetadataItemSchema, MetadataOp, MetadataOpSchema, MetadataRepository, MetadataType, MetadataTypeSchema, MetadataWriteIntent, NotFoundError, OBJECT_FIELD_TYPE_REFUSED_ERROR_NAME, OBJECT_SCHEMA_MASK_DISABLE_ENV, OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES, OBJECT_SCHEMA_MASK_NOT_APPLICABLE, OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, OBJECT_SCHEMA_WRITE_CAPABILITIES, ORG_PRESENTATION_AUTHORING_CAPABILITY, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF, ObjectFieldTypeRefusal, ObjectFieldTypeViolation, ObjectSchemaMaskEvaluationError, ObjectSchemaMaskPassthroughReason, ObjectSchemaMaskPosture, ObjectSchemaMaskResult, ObjectSchemaMaskSecuritySurface, ObjectSchemaMaskTelemetry, ProtocolCompatResult, ProtocolHandshakeManifest, ProtocolIncompatibleAnswer, ProtocolIncompatibleDiagnostic, ProtocolIncompatibleError, PutOptions, PutResult, RangeSource, RecordOrganizationResolver, SchemaValidationError, SysMetadata, SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject, SysViewDefinitionObject, TENANT_SCOPE_FIELD_DEF, UnboundFormPredicateRoot, WarnFn, WatchFilter, anonymousFormIntakeCandidates, anonymousFormIntakePosture, anonymousFormIntakeSlug, anonymousFormIntakeSlugs, anonymousFormIntakeUnavailability, anonymousFormIntakeUnavailableMessage, anonymousFormIntakeUnavailableRemedy, anonymousFormObjectName, anonymousFormSharingPath, applyArtifactForwardConversions, applyAuditFieldGovernance, applyInjectedSystemColumns, applyObjectSchemaMask, assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, assertProtocolCompat, canonicalize, checkProtocolCompat, createFieldPresenceProbe, createRecordOrganizationResolver, createRecordWallOrganizationResolver, declaresOrgOverride, describeUndeclarableFieldType, detectUnboundFormViewPredicateRoots, engineByIdUnhonouredPredicateMessage, engineFindOnePredicateRefusalMessage, engineUpdateDispatchRejectError, engineUpdateIdConflictMessage, engineUpdateIdPredicateConflictMessage, findUndeclarableFieldType, foldVisibilityFingerprintIntoEtag, hashSpec, injectedSystemColumnDefs, isCodeArtifactBody, isDeclarableFieldType, isObjectFieldTypeRefused, isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, isProtocolIncompatibleError, isTenantAuthored, itemDiscriminator, metaWriteCapabilityVerdict, normalizeIfNoneMatch, objectFieldVisibilityFingerprint, organizationIdForMetaRead, organizationIdForMetaWrite, parseRangeFloor, platformProvisionsStorage, protocolIncompatibleAnswer, publicFormSlug, rangeAdmitsMajor, readDiscriminatorValue, refKey, resolveDeclaredRange, resolveEngineDeleteDispatch, resolveEngineFindOnePredicate, resolveEngineUpdateDispatch, resolveInjectedColumnProvenance, resolveInstalledSpecVersion, resolveObjectSchemaMaskPosture, resolveRecordOrganizationField, resolveRecordWallOrganizationField, scalarDeleteId, scalarUpdateId, stripInjectedSystemColumns, unboundRootsInCelSource, unhonouredByIdPredicateKeys, unprovisionedInjectedColumns

Tests (HEAD 6ca235b9)

  • marketplace-install-local-protocol-handshake.test.ts (new, 11 pins):
    • The inline-manifest and cloud-snapshot refusals: 422, declared envelope (BaseResponseSchema, envelopeViolations, ApiErrorSchema), OS_PROTOCOL_INCOMPATIBLE, exactly the five details. Nothing registered, no ledger file, 0 syncs.
    • The range is judged before the package's code, with a control: the same handler-only job under ^17 answers VALIDATION_ERROR.
    • A refused upgrade leaves the installed ledger file byte-identical.
    • ^17 control: 200, registered, written, synced.
    • No-range control: 200, with one [protocol] warning on the plugin logger.
    • Parity: POST /api/v1/packages, driven through the runtime's real HttpDispatcher, and install-local give byte-equal {status, code, message, details}.
    • Rehydrate: the incompatible entry is not registered, has 0 syncs and produces one error line naming the code, the id and objectstack migrate meta --from 16. The boot continues (a compatible entry rehydrates and the routes mount). DELETE still removes the entry, and a ^17 version replaces it.
  • protocol-handshake.test.ts (+3): the helper's status, code and message; exactly five details members valued from the diagnostic; closed shape (a member added to the diagnostic does not leak).
  • packages-install-protocol-incompatible.test.ts: the header's "only HTTP door" claim is updated. Its 6 pins still pass unchanged, so the packages door's wire is the same after the switch to the helper.
  • Full suites: metadata-core 18 files / 374 passed. cloud-connection 39 / 477 passed. runtime 325 / 4624 passed (19 skipped). typecheck exits 0 for all three, and --listFiles confirms both new test files are in their programs.

Ablations (scripts/ablation-replace.mjs, each restored to the HEAD blob with git diff HEAD empty)

  • A: install handshake call replaced with a no-op. Anchor 1 → 0, blob 2ce1f2e4 → e1c43b2a. 6 of 11 went red. The inline refusal read expected 200 to be 422, the card's defect. The ordering pin read VALIDATION_ERROR, the no-range warning count read 0, and parity failed. The 5 rehydrate and control pins stayed green.
  • B: rehydrate refusal disabled. 2 of 11 went red: the not-loaded pin (expected [ …(2) ] to not include 'com.example.qaold') and boot-continues. The DELETE and replace preservation pins stayed green.
  • C: helper's details turned into a spread of the diagnostic. My first attempt was a no-op: the replacement contained the anchor, the tool counted it 1 → 1 and refused, and nothing ran. Re-anchored, the mutation landed (blob 062e9469 → 7a0a3d18): 2 of 25 went red in metadata-core (exact members, closed shape) and 5 of 11 in cloud-connection. Parity went red too, because install-local read the aliased mutated source while the packages door read the built dist.
  • No build sat between mutation and run: each subject is imported relatively from src, or through the existing metadata-core source alias.

Gates (HEAD 6ca235b9)

  • node scripts/pm/dispatch-gates.mjs --commands derived 74 commands from this change set. All 74 ran, and --ran reports: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN.
  • Two of them first exited 3 (prerequisite, not red). check-plugin-teardown-shape --self-test needed its pinned positive-control commit in this shallow clone; after fetching it, 48 cases passed. check:dual-build-cjs-loads needed every package's dist/; after pnpm build (72/72 tasks), it exited 0.
  • pnpm lint (the full eslint . --no-inline-config) exited 0 with no findings.
  • origin/main has moved to d13df0c6 (3 commits). None of them touches metadata-core, runtime, cloud-connection or pnpm-lock.yaml, so I did not merge.

Acceptance notes

  • packages/spec/src/api/error-code-ledger.zod.ts: the OS_PROTOCOL_INCOMPATIBLE row's comment still says "The one HTTP door that reaches the throw, POST /api/v1/packages". There are two doors now. That file belongs to the domain:spec seat, so this PR does not edit it.
  • packages/runtime/src/app-plugin.test.ts (the 422 boot-seam case): its comment calls AppPlugin "the one other caller of assertProtocolCompat". There are three callers now. This is comment drift and is not edited here.
  • The GET install-local listing still serves an entry the rehydrate refused, because it reads the ledger. Only the error log says the entry is not loaded. This matches the posture this door already keeps for an unreadable ledger entry (log, wire unchanged).
  • Reseed and purge on a refused entry were not measured.
  • The handshake judges the top-level manifest only. A multi-package artifact's per-package ranges were not measured at this door, and POST /api/v1/packages and AppPlugin judge the same scope.
  • Envelope dialect: install-local's hand-built errors carry no error.httpStatus, and the dispatcher's carry it on every exit. This predates this PR and is door-wide.

Generated by Claude Code

claude added 3 commits October 5, 2026 01:52
…l-local and its rehydrate

POST /api/v1/marketplace/install-local now calls assertProtocolCompat before
anything is registered, written or synced, and refuses an incompatible
manifest with the answer POST /api/v1/packages gives: 422
OS_PROTOCOL_INCOMPATIBLE with the diagnostic's five fields in error.details.
That answer is one shared helper, protocolIncompatibleAnswer, beside
ProtocolIncompatibleError in @objectstack/metadata-core; the packages door's
module-private copy is gone and it calls the helper too.

The kernel:ready rehydrate no longer loads a ledger entry whose range excludes
this runtime's major: it logs the refusal at error, naming the replay command,
and the boot continues.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
…he rehydrate skip

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 5, 2026
@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cloud-connection, @objectstack/metadata-core, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cloud-connection/package.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/ai/connect-mcp.mdx (via protocolVersion (literal, a string literal in details))
  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in a comment in handleInstall; a path literal in a comment on a changed line))
  • content/docs/deployment/cli.mdx (via MarketplaceInstallLocalPlugin (symbol, a top-level class))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in a comment in handleInstall; a path literal in a comment on a changed line))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in a comment in handleInstall; 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 handleInstall; a path literal in a comment on a changed line))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in a comment in handleInstall; a path literal in a comment on a changed line))
  • 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 handleInstall; 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 handleInstall; 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 handleInstall; a path literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages (route, a path literal in a comment in handleInstall; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal in a comment in handleInstall; a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-5.mdx (via protocolVersion (literal, a string literal in details), /api/v1/packages (route, a path literal in a comment in handleInstall; 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 changed file(s) yielded no anchor (packages/cloud-connection/package.json) — pages documenting those are invisible to this run
  • 2 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 — 29 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 d13df0c630cee5c24b03391a375941a2424dd9d2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 88ac8501153cdc9fcffcff30ee45d9205bad8ad7 — the merge of head 6ca235b90b92737f4c5b8e6acf5aeb114b3438c7 into base d13df0c630cee5c24b03391a375941a2424dd9d2, 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 88ac8501153cdc9fcffcff30ee45d9205bad8ad7 && git checkout 88ac8501153cdc9fcffcff30ee45d9205bad8ad7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d13df0c630cee5c24b03391a375941a2424dd9d2 6ca235b90b92737f4c5b8e6acf5aeb114b3438c7 && git checkout -B drift-repro d13df0c630cee5c24b03391a375941a2424dd9d2 && git merge --no-ff 6ca235b90b92737f4c5b8e6acf5aeb114b3438c7

node scripts/docs-audit/affected-docs.mjs --json d13df0c630cee5c24b03391a375941a2424dd9d2

⚠️ 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 d13df0c630cee5c24b03391a375941a2424dd9d2 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6ca235b90b92737f4c5b8e6acf5aeb114b3438c7
Local-runs: none

Inputs: card #21762 (body and all five comments: triage 5982323247, unlock 5986276908, claim 5986651164, os-dev-report 5987099192, ACCEPT 5987132296), PR #21805 (body, 9-file list, diff), the check-runs on the head. The PR head was still 6ca235b9 when read. GitHub's diff equals git diff e27a7c0c 6ca235b9 (merge-base, 9 files, +596/-52). origin/main is at 0a348031, four commits past the merge-base (the PR body names three, at d13df0c6); none of the 46 files main changed is among the PR's nine, and an in-memory merge of the two is conflict-free. Governing texts read on origin/main: AGENTS.md Post-Task Checklist step 3, execution-duties.md lines 67-71 and 102-108, contract-review.md, clause2-line.mjs, ADR-0087 D1.

① Derived judgments

Public surface, per package entry (surface = what the built entry declarations reach):

  1. @objectstack/metadata-core . — ADDED ProtocolIncompatibleAnswer (interface: status = ProtocolIncompatibleError['status'], the literal 422; code = ProtocolIncompatibleError['code'], the literal OS_PROTOCOL_INCOMPATIBLE; message: string; details = Pick of ProtocolIncompatibleDiagnostic over requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand). ADDED protocolIncompatibleAnswer(err: ProtocolIncompatibleError): ProtocolIncompatibleAnswer. REMOVED none. Types reached through the two: ProtocolIncompatibleError, ProtocolIncompatibleDiagnostic, RangeSource (via rangeSource), all exported from the same module at BASE with unchanged declaration text. src/index.ts:16 is export * from './protocol-handshake.js', so both names land on .. Right: this entry widens by exactly two names. ./testing (src/testing.ts) re-exports only contract-suite and object-schema-fls-contract: unchanged, right. The 169/171 count and the five SysMetadata*Object union-order re-hashes are build readings I did not repeat; the delta they report is what the source implies.
  2. @objectstack/runtime . — the deleted protocolIncompatibleAnswer(deps, err) was a non-exported module function of domains/packages.ts; handlePackagesRequest's signature is unchanged; index.ts does not re-export that module; the dropped type ProtocolIncompatibleError import reached no declaration. Surface unchanged: right. Wire of POST /api/v1/packages: deps.error(message, 422, { code, requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }) before and after, same keys in the same order. Byte-identical: right (the 6 pins of packages-install-protocol-incompatible.test.ts change only in their header comment, and Test Core is green).
  3. @objectstack/cloud-connection . — src/index.ts is not in the diff. MarketplaceInstallLocalPlugin gains the arrow property private reportProtocolIncompatibleEntry, which the declaration emit prints untyped, so ProtocolIncompatibleDiagnostic does not enter this closure, and a class that already had private members keeps the same nominal assignability. Surface unchanged: right.
  4. Dependency edge — @objectstack/metadata-core moves from devDependencies to dependencies in cloud-connection; the lockfile hunk is only that importer move. At BASE no production file under packages/cloud-connection/src imported metadata-core (only test-side files did, through the vitest source alias), so the dev-only placement was right then and the move is required now (four value imports). metadata-core depends only on spec and zod: no cycle. runtime and core already depend on it: no new package in the install closure. Right; not a Clause-② event (no built declaration moves).

Accept-set changes, each named:

  1. POST /api/v1/marketplace/install-local — a manifest whose resolved range (engines.protocol, then engines.platform, then engine.objectstack: resolveDeclaredRange) excludes PROTOCOL_MAJOR answered 200 (registered, ledger written, synced) and now answers 422 OS_PROTOCOL_INCOMPATIBLE with error.details = exactly the five members, on the inline and the cloud-snapshot branch, after the id gate and before the unrunnable-code judgement, the collision check, the posture gate, register, the ledger write and syncSchemas. A narrowing of the door's observed accept set, judged right, and judged a pull-back to a declared contract rather than a Clause-② narrowing (the reasoning is in ②). No-range and unparsed-range manifests still install; the handshake's [protocol] warning now reaches ctx.logger.warn (additive, log only). The 11 pins assert each of these and the untouched ledger file on a refused upgrade.
  2. kernel:ready rehydrate — an incompatible ledger entry is skipped before register, syncSchemas, bindArtifactHandlers, applySideEffects and maybeHealSampleData; one error line names the code, id@version and the handshake message ending in the replay command; the boot continues; the entry stays, so DELETE and a compatible re-install work (pinned). checkProtocolCompat is acted on for incompatible only, so no-range and unparsed entries rehydrate exactly as at BASE. Right, and the triage's "rehydrate behaviour, measured and pinned" is met: BASE loaded the entry silently (the dev's H6 probe), HEAD pins the opposite.
  3. Error-code surface — no new code. OS_PROTOCOL_INCOMPATIBLE sits in ERROR_CODE_LEDGER under its producer @objectstack/metadata-core at 422; cloud-connection relays the producer's own error object, so no ledger row is owed, and every check on the head is green. Right.
  4. Envelope — install-local's hand-built { success: false, error: { code, message, details } } is the shape of every other refusal in that handler (none carries httpStatus); the parity pin holds { status, code, message, details } byte-equal with the packages door, driven through the real HttpDispatcher. Right.
  5. Recogniser — one shaping helper (protocolIncompatibleAnswer) beside the existing brand predicate (isProtocolIncompatibleError), both doors call both. A grep of the head's production sources finds the five details members shaped nowhere but metadata-core/src/protocol-handshake.ts (the CLI's protocol-version-gap.ts and service-package read checkProtocolCompat for prose, not an HTTP answer). No second copy: holds.

Check-runs on 6ca235b9, latest per name, 35 names with one run each: 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 0 red. Green includes Check Changeset, Lint and Repo Gates, the four Type Check jobs, Test Core 1-6 and its rollup, Build Core, Build Docs, Dogfood Regression Gate 1-3 and Verify CLI, Temporal Conformance, Validate Package Dependencies, Governed Surface Queue Guard, the single-writer and card-claim guards.

② Semver level

Changeset .changeset/21762-install-local-protocol-handshake.md: @objectstack/metadata-core minor, @objectstack/cloud-connection patch, @objectstack/runtime patch. All three are in the fixed group and released (17.6.0, none marked private). Clause-②: yes (widening) stands on its own line in the changeset body and at the head of the PR body, so both gate readers see one spelling.

  • metadata-core minor: right — the . entry widens by two names, and yes takes at least minor (AGENTS.md Post-Task Checklist step 3; execution-duties.md:71).
  • cloud-connection patch: right — a bug fix in a released package whose built declarations do not move (step 3: a bug fix in a released package takes a patch, never none, never skip-changeset); the dependency move adds no package to the closure.
  • runtime patch: right — source moved, surface and wire did not; a released package whose source changed owes a changeset, and skip-changeset would be wrong.

Clause-②: yes (widening) is the right single arm. The dev's open_questions[0] (A keep yes (widening) / B re-declare yes (narrowing)) and the seat's ACCEPT answer A were weighed against the rule text, not adopted:

  • The line answers one question over the PUBLISHED contract surface (execution-duties.md:67, :105: Clause-② names only the published contract face; pulling back to a declared contract does not touch it). The published texts on whether this door admits an out-of-range manifest are: ManifestSchema.engines.protocol in @objectstack/spec (declared, ADR-0078 says a declared field is enforced or removed), ADR-0087 D1 ("the package installer checks engines.protocol ... before loading a package's metadata; major-incompatible and not convertible: fail fast with a structured diagnostic"), and the OS_PROTOCOL_INCOMPATIBLE ledger row at 422 in @objectstack/spec. All three said refuse before this PR. No published text said install-local admits such a manifest; the 200 was the declared-but-inert gap the card files as a class (b) defect, and the sibling door, the boot seam (AppPlugin) and the metadata-protocol install primitive already enforced the same sentence. So the 200 to 422 move is 拉回已声明契约, outside Clause-②. The rehydrate's refusal is the same D1 sentence at the load seam.
  • The citation discipline :106-107 imposes on the mirror case (a pull-back must cite the declared text; a citation that does not fit demotes the reading) is met: the dev and the seat cite D1 and the sibling door; I add the ManifestSchema field and the ledger row at 422, and both fit.
  • clause2-line.mjs gives yes (narrowing) to a diff that "widens one surface and narrows another"; the narrowed thing here is not a published accept set, so the arm does not apply, and taking it would register a BREAKING change with an ADR-0087 disposition marker for a diff that registers nothing new and removes nothing authorable. Option B is wrong. Option A is right.
  • The changeset's closing sentence (a client that relied on install-local accepting an out-of-range package gets 422; install a compatible version or run migrateCommand) is the CHANGELOG text an upgrading agent greps and is right to keep; it does not make the change a Clause-② narrowing.

Changeset sentences against the diff: the range order matches resolveDeclaredRange; the five details members match the helper; "an installed earlier version stays as it was" is pinned byte-identical; the restart sentence matches the rehydrate skip and the one error line; the metadata-core export sentence names the two names; the runtime sentence ("response unchanged") is right.

③ Boundary flags

Dev open_questions[0] — answered: A. Keep Clause-②: yes (widening), metadata-core minor, cloud-connection and runtime patch. No re-declaration, no BREAKING banner, no disposition marker. Reasoning in ②.

Dev deviations, each answered:

  1. Clause-② surface measured on built declarations — verified against the source (①.1-3); right.
  2. Recogniser = existing predicate + new typed helper, not one function taking unknown — accepted: the ruling asked for one helper both doors call, with no second copy; the predicate is already the shared brand check, and ①.9 finds no copy.
  3. Rehydrate acts on incompatible only, no new boot warning for no-range entries; the install door now warns on no-range — accepted. D1's "range absent: load with a warning" is now met at the install door; a silent rehydrate of a no-range entry is BASE posture and outside the card's pins. Fine to leave.
  4. Cloud-snapshot refusal answers 422, not the id gate's 502 — right: the unrunnable-code refusal in the same handler answers one status on both branches for the same reason; the body is the thing refused, not the upstream.
  5. main not merged — fine: verified at 0a348031, no file overlap, clean merge-tree.
  6. Lockfile deprecated: annotations reverted — fine: the hunk is only the importer move.
  7. --maxWorkers possibly dropped locally — immaterial: the Test Core shards on the head are the gate verdicts.
  8. Commit trailer choice — not a review input.
  9. Shallow-clone fetch of the teardown-shape control commit — local only.

PR body Acceptance notes, each graded:

  • (a) packages/spec/src/api/error-code-ledger.zod.ts:639-642 still says "The one HTTP door that reaches the throw, POST /api/v1/packages" (verified on origin/main). Must be filed: prose in a published-contract file now states a falsehood this PR creates; the claim forbids a packages/spec path here; the seat's pointer on seat post [PM seat] domain:spec — 🟢 os-litant · session_01LAi5BVvQNiYzepSAcsoFLK #6017 is not a card. One-line comment fix in the domain:spec lane.
  • (b) packages/runtime/src/app-plugin.test.ts:504 "The one other caller of assertProtocolCompat" — fine to leave: a test comment, and already inexact at BASE (the metadata-protocol install primitive at protocol.ts also calls it).
  • (c) GET install-local still lists an entry the rehydrate refused — the dev's "same posture as an unreadable entry" does not hold on the wire: handleList drops an unreadable entry from items (its warning reads "MISSING from the installed-apps list served to the console"), while a protocol-refused entry is served as installed with no marker. Must be filed (p3, follow-up card): the listing owes a marker or omission for a ledger entry the rehydrate refused. Not this card's pin set; the error line carries it today and DELETE works.
  • (d) Reseed and purge on a refused entry not measured — must be filed (p3, follow-up card): handleReseed reads the ledger entry and calls applySideEffects(seedNow: true), which loads manifest.translations into i18n and merges manifest.data into seed-datasets before seeding, so on a protocol-refused entry a public door performs part of the load D1 says not to perform, unguarded by the handshake; handlePurge acts on the same entry. Both doors are outside the card's surface and pins.
  • (e) Top-level manifest only, per-package ranges unjudged — fine to leave: identical scope to POST /api/v1/packages and AppPlugin; a change would be cross-door and cross-card.
  • (f) error.httpStatus dialect — fine to leave: BASE-wide on every install-local exit; both envelopes parse as ApiErrorSchema.

Escalations: none. No flag needs a ruling beyond the seat's; (a), (c) and (d) are filings the seat makes, not conditions on this head.

Implemented-by: claude/issue-21762-install-local-protocol-handshake
Reviewed-by: session_01RWZbGvPFcRKvUqASZtunCU
Independence: INDEPENDENT AGENT

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 03:18
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 03:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 75ddcd1 Oct 5, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21762-install-local-protocol-handshake branch October 5, 2026 03:58
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… decision in words instead of a tracker number (stage 15) (objectstack-ai#21810)

Part of objectstack-ai#20749
Clause-②: no

Stage 15 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the first name-ordered file group directly under
`packages/spec/src/data/`: the 20 test files from
`aggregate-field-type-compatibility.test.ts` to
`date-range-presets.test.ts`. They carried 94 messages and 102 tracker
ids, citing 60 records. Every one of those ids now either states what
its record decided, in words (form D), or is dropped where the title
already says it. Text only: no assertion, identifier, test count or code
comment changes.

## Census at the base (`0a3480311a`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the
copies stages 10 to 14 used. A literal counts as a test title when its
folded message is argument 0 of a `describe` / `it` / `test` call,
`.each` / `.skip` / `.only` chains included. Everything else is an
"other" string.

Both instruments read **1325 messages / 1406 ids in 282 files**, the
seat's reading at `0a3480311a` (stage 14's head).

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` (this PR: the first 20 files) | 95 | 468 / 501 | 445 / 475 |
23 / 26 |
| `ui/` | 81 | 393 / 416 | 375 / 398 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **282** | **1325 / 1406** | **1246 / 1323** | **79 / 83**
|

The group reads **94 messages / 102 ids in 20 files**, the seat's
figures, file for file:

| file (under `data/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `aggregate-field-type-compatibility.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `analytics-date-range-closed-vocabulary.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `analytics-date-range-two-bound-window.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `analytics-query-window-integer.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `analytics-strictness-batchd.test.ts` | 9 / 9 | 9 / 9 | 0 |
| `analytics.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `api-derivation.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `api-methods-batch-conformance.test.ts` | 3 / 4 | 1 / 1 | 2 / 3 |
| `authoring-key-lint.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `autonumber-format.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `autonumber-unanchored-boundary.test.ts` | 3 / 4 | 3 / 4 | 0 |
| `bulk-write-hook-conformance.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `calendar-day.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `context-tokens.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `currency-mode-family-closure.pin.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `currency-precision-iso4217.test.ts` | 7 / 7 | 7 / 7 | 0 |
| `data-engine.test.ts` | 20 / 25 | 20 / 25 | 0 |
| `datasource-credential-redaction.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `datasource.test.ts` | 10 / 10 | 10 / 10 | 0 |
| `date-range-presets.test.ts` | 2 / 3 | 2 / 3 | 0 |
| **20 files** | **94 / 102** | **92 / 99** | **2 / 3** |

- **Controls.** Lit, a title: `data/document.test.ts` reads 2 / 2 at the
head. Lit, "other" strings: the two in
`data/external-lookup-retirement.test.ts` (`:89`, `:130`) still read at
the head. Dark: the file comment at
`data/analytics-strictness-batchd.test.ts:4` (it names the strictness
batch by its number) reads 0. Planted in a scratch copy of the head
`data/calendar-day.test.ts`: an id put into a title reads 1 / 1, and an
id put into a comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals in 19
of the 20 files. In `aggregate-field-type-compatibility.test.ts` it
reads one more, a decision-batch number at `:150` that sits beside a
cited record in the same literal. The gate's pattern needs three to five
digits, so it is not counted there.
- **At the head:** 1231 messages / 1304 ids in 262 files. The 20 files
read 0 / 0 on both patterns, and no other file moved.

## How the area was chosen

`data/` has no subdirectory to split by (449 ids directly under it,
`data/driver/` 52), so its stages take name-ordered file groups near the
~100-id bound, as stage 14's report proposed. This census reads the
first group at exactly 102, the claim's figure, so the rule needed no
re-cut.

**Named for the next stages** (re-cut from the head census, 1231 / 1304;
`data/` 374 / 399 left):
- `data/` in four more stages, name-ordered:
1. `default-value-shape.test.ts` to `filter-comparand-shape.test.ts`: 20
files, 94 messages / 100 ids;
2. `filter-comparand-type.test.ts` to
`filter-view-operator-parity.test.ts`: 20 files, 95 / 99;
3. `filter.test.ts` to `object.test.ts`: 17 files, 103 / 114.
`object.test.ts` alone carries 42, so no cut lands nearer the bound;
4. `query-transport.test.ts` to `validation.test.ts` (11 files, 34 / 34)
with `data/driver/` (7 files, 48 / 52): 86 ids.
- `ui/` 416, about four stages. `api/` 201, two. `system/` 165, two. The
files directly in `src/`, 120, one.
- The three docblock needles (`ai/build-progress.test.ts:236`, `:237`,
`contracts/approval-service.test.ts:274`), one stage with their
docblocks.

## What each id became

24 literals (28 ids) now state a decision in words. 2 literals (2 ids)
get their subject back in words where the number stood in for it. 69
literals (72 ids) drop a number the title already explains. (95 literals
in 94 messages: the `sys_organization` reason string is one message over
two lines.)

Every cited record was read with its comments through REST: 55 answer
200. objectstack-ai#6345, objectstack-ai#8876, objectstack-ai#9040, objectstack-ai#10194 and objectstack-ai#17014 answer 404, and their
decisions were read from what landed: `e2798fa` (one driver vocabulary
for start and migrate), `d634e66` (the username half of the URL userinfo
grammar), `2420641` (a credential in the mongo `options` passthrough is
refused), `2306a76` (`theme` / `analytics_cube` validated at the `/meta`
write door) and `80aef80` (a one-day window for the one-day presets),
each with its CHANGELOG entry. No cross-repo record is cited in this
group.

| record(s) | literal (under `data/`) | now reads |
|:--|:--|:--|
| objectstack-ai#11152 | `aggregate-field-type-compatibility.test.ts:150` | "accepts
`sum` / `avg` / `min` / `max` over booleans — numbers on every backend,
a ruling that outranks the refused-by-default rule". The maintainer
ruled that booleans aggregate as numbers on every backend; decision
batch 80 held that ruling over batch 59's blanket refusal of unnamed
pairs. That batch number went with the id. |
| objectstack-ai#4001 (3) | `analytics-strictness-batchd.test.ts:83`, `:248`, `:306` |
"batch D, unknown keys refused — …" before "the doors the cube family is
reachable through", "alias claims are true of the surfaces they point
at" and "deliberate non-closures (re-verdicts, not omissions)". The
campaign's decision: an unknown key is refused, not stripped. |
| objectstack-ai#3878 (2) | `analytics-strictness-batchd.test.ts:270`, `:297` |
"matching the dispatcher's bespoke hint at the /analytics entry" and
"the retired-envelope tombstones still fire". The body is the bare
`AnalyticsQuery`; the `{ cube, query }` envelope was retired with
tombstones, and the entry answers 400 with a hint at `where`. |
| objectstack-ai#18612 | `analytics.test.ts:314` | "a persisted cube heals at the door
— the retired join `sql` / `relationship` are stripped (ADR-0087 D2)". |
| objectstack-ai#3391 | `api-derivation.test.ts:16` | "api-derivation — one table
resolves the effective operations from six primitives". The server is
the only adjudicator, through one derivation table. |
| objectstack-ai#3543 | `api-derivation.test.ts:286` | "vocabulary split — authors
write six primitives, the wire speaks operations". The authored enum
shrank; the wire vocabulary stayed byte-stable. |
| objectstack-ai#15873 | `api-methods-batch-conformance.test.ts:221` | A declared
reason string: "(a ruling grants `update`; both are column-clamped per
row by ADR-0092 D2)". Option (a), decision batch 64. |
| objectstack-ai#3786 | `authoring-key-lint.test.ts:37` | "lintAuthoredRecordKeys — an
unknown authoring key is reported, not swallowed", the decision its
source docblock records. |
| objectstack-ai#6555 | `autonumber-format.test.ts:23` | "DEFAULT_AUTONUMBER_FORMAT /
resolveAutonumberFormat — one declared default both sides read". Route
3: `{0000}` became the contract default, and both fallbacks went away. |
| objectstack-ai#5038 | `bulk-write-hook-conformance.test.ts:114` | "records the after
half as DELIVERED — the engine fires it once per row". |
| objectstack-ai#5574 | `bulk-write-hook-conformance.test.ts:119` | "records the
before half as DELIVERED — the engine dispatches it per row too". |
| objectstack-ai#20126 | `currency-mode-family-closure.pin.test.ts:348` |
"currency-mode family — the enumerating closure pin: `defaultCurrency`
holds only under `fixed`". |
| objectstack-ai#19992 | `currency-precision-iso4217.test.ts:163` | "the removed
`currencyConfig.precision` at rest: a stored row carrying the baked
`precision: 2` is served canonical". |
| objectstack-ai#7918 | `currency-precision-iso4217.test.ts:224` | "… where the ISO
4217 width check used to refuse it". That check was the record's option
A, later reversed. |
| objectstack-ai#3407, objectstack-ai#6437 | `data-engine.test.ts:1185` |
"DroppedFieldsEventSchema.reason — why a write dropped submitted fields,
widened past the readonly pair". |
| objectstack-ai#6262, objectstack-ai#6433, objectstack-ai#6435 | `data-engine.test.ts:1198` | "primary_key is the
value the engine reports when it strips a payload id it ruled is not an
identifier", the schema's own wording of that strip on the bulk and the
by-id paths. |
| objectstack-ai#8300 | `datasource-credential-redaction.test.ts:70` | "(the drift
guard on the one credential-key definition)". |
| objectstack-ai#8876 | `datasource-credential-redaction.test.ts:232` | "— the
username half of the same alignment". |
| objectstack-ai#8337 | `datasource-credential-redaction.test.ts:243` |
"redactUrlCredentialQueryParams — the read half: a credential query
parameter is never served back". |
| objectstack-ai#8153 | `datasource.test.ts:673` | "— unchanged by the managed-row
credentialsRef allowance". The ruling allowed `external.credentialsRef`,
and only it, on managed rows. |
| objectstack-ai#4614, objectstack-ai#8793 | `date-range-presets.test.ts:14` | "date-range preset
vocabulary — one source of truth, read by both the UI and the data
side". |

**Subject restored (2 ids):** objectstack-ai#20126 at
`currency-mode-family-closure.pin.test.ts:403` ("currency-mode closure
controls — each rule can fail, and passes what it must") and objectstack-ai#7918 at
`currency-precision-iso4217.test.ts:311` ("carries the measured anchors
— 0 digits for JPY, 2 for USD, 3 for KWD"). That literal moved from
double to single quotes, since it no longer holds an apostrophe.

**Dropped only (72 ids):** objectstack-ai#1603, objectstack-ai#2377, objectstack-ai#3026, objectstack-ai#3391, objectstack-ai#3543, objectstack-ai#3545,
objectstack-ai#3795 (9), objectstack-ai#4001 (2), objectstack-ai#4286, objectstack-ai#4346 (2), objectstack-ai#4538, objectstack-ai#4583, objectstack-ai#5586, objectstack-ai#6345,
objectstack-ai#6555, objectstack-ai#6560, objectstack-ai#7178 (5), objectstack-ai#7265, objectstack-ai#7287 (2), objectstack-ai#7802 (2), objectstack-ai#8032, objectstack-ai#8057 (2),
objectstack-ai#8153 (7), objectstack-ai#8336, objectstack-ai#8337, objectstack-ai#9040, objectstack-ai#10194, objectstack-ai#10414, objectstack-ai#13802, objectstack-ai#16041, objectstack-ai#16632,
objectstack-ai#17014, objectstack-ai#17296, objectstack-ai#17598 (2), objectstack-ai#18278, objectstack-ai#19992 (3), objectstack-ai#20011, objectstack-ai#20300 (2),
objectstack-ai#20550, objectstack-ai#20600, objectstack-ai#20808 (3), objectstack-ai#21365 (2).

- Each of these titles already states the decision it pins: for example
"empty array → deny-all (flipped semantics)" for objectstack-ai#3391, "accepts the
BARE query string — the canonical ADR-0061 D1 spelling" for objectstack-ai#7178, or
"`currencyConfig.precision` is removed: refused with the prescription,
whatever its value" for objectstack-ai#19992.
- **Small rewordings that carry no new claim:**
`analytics-strictness-batchd.test.ts:307` reads "are CLOSED now" where
it named the record; `autonumber-unanchored-boundary.test.ts:51` reads
"(ruled: mixed content is out of contract)"; `datasource.test.ts:553`
reads "(the happy path)". The circled part numbers after objectstack-ai#17598 went
with the id.
- **The two `api-methods-batch-conformance.test.ts` reason strings**
(`sys_api_key`, `sys_organization`) end "rather than hitting /batch."
now. The table is read only through `!== undefined`, so no assertion
reads their text.

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. No `__snapshots__` directory exists under
`data/`, and no `.snap` file is tracked under `packages/spec`.
- **Projects:** two touched files are listed in
`packages/spec/vitest.repo-tests.json`:
`api-methods-batch-conformance.test.ts` and
`currency-mode-family-closure.pin.test.ts`. Both were run in the `repo`
project at the base and at the head, and the other 18 in `local`.
- **By substring:** every old literal, plus a window around each id (289
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The 14 hits are:
- **sibling titles in other lanes:** `service-analytics`
`aggregate-nontemporal-measure-refusal.test.ts:344` and `objectql`
`engine-autonumber-default-format.test.ts:248`;
- **this card's later `data/` stage:**
`data/driver/postgres.test.ts:169`, the same "placeholders are not
resolved here" title, already in the census;
- **comments, CHANGELOG, an audit ledger and liveness evidence:** `lint`
`validate-dataset-measure-aggregates.test.ts:179`, `service-analytics`
`dataset-compiler.ts:227`, `objectql` `engine.ts:6206` and `:6272`,
`analytics.zod.ts:1002`,
`docs/audits/2026-07-unknown-key-strictness-ledger.md:728`, two
`packages/spec/CHANGELOG.md` entries and the `liveness/field.json:218`
evidence string, which quotes the `engine.ts` comment. None reads a test
title.
- **Same-text titles named in stage 14's ACCEPT** (`(objectstack-ai#15680)`,
`(objectstack-ai#5955)`, `objectstack-ai#3896 close-out`): none falls in this group.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are the three
reason-string leaves in `api-methods-batch-conformance.test.ts`.

- **Result:** 20 of 20 files SAME on all three legs, as predicted in
writing before the run.
- **Totals:** 95 changed literals, 92 titles and 3 declared. The diff's
`+` and `-` lines are exactly the 95 planned lines, and every file keeps
its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string given a
new id VIOLATION; an undeclared `expect` message changed VIOLATION; a
title re-split into a `+` chain DIFF.

**Test counts:** the 20 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 553 / 553 passed, with the same count and status sequence per
file in 20 of 20. 291 full test names change, and each equals the base
name with the planned replacements applied (0 mismatches). No full name
repeats on either side.

## `main` merged in, once

objectstack-ai#21800 (the console pin bump) landed while this branch was being
verified, and it rewrites the comment block at `:61-77` of
`api-methods-batch-conformance.test.ts`. This PR edits only string
literals in that file, more than 100 lines below the block, so
`origin/main` (`18c2ddc1ec`, which also carries objectstack-ai#21801) was merged in
with a plain merge, no rebase, and no conflict. The PR's delta against
`main` is still exactly the 20 files, +95 / -95. Every reading in this
body was re-taken on the merged head `bf16ad1190`, against `18c2ddc1ec`
as the base: the census (1325 / 1406 there, 1231 / 1304 here, unchanged
by the two commits), the text-only proof and its controls (the three
declared lines now sit at `:202`, `:230` and `:235`), the 20-file runs,
the full build, the suite, the typecheck and the gates. Re-fetched just
before this PR opened, `origin/main` was one commit further
(`75ddcd1b41`, objectstack-ai#21805, in `cloud-connection`, `metadata-core` and
`runtime`). It touches no `packages/spec` path and no file here, so it
was not merged.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
20 touched files are in it, and no `*.test.ts` at all. Of `src/`, only
the `*.zod.ts` sources ship: the controls `src/data/analytics.zod.ts`,
`src/data/data-engine.zod.ts` and `dist/data/index.js` are in it.
- In the built `dist/`, five new phrases and four old literals each read
in 0 files. The control `Unrecognized key(s) on` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `bf16ad1190`)

- `pnpm turbo run build` over all packages: 71 / 71 (also 71 / 71 at the
pre-merge head `89c4b300c2`).
- `@objectstack/spec`:
  - `vitest run --project local`: 615 files, 18360 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 20 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date after the
merge.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stages 13 and 14, and all 79 exit 0. `--ran` reconciles: 79
derived, 79 run, 0 NOT-MEASURED, 0 UNRUN.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 20 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 20 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 190 changed lines.

## Acceptance notes

- **No needle in this group.** Every id was a title or a declared reason
string; no expected value of an assertion over a source docblock was
found. The three known needles are untouched.
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec` finds 156 lines citing ids this PR handled, in 79 files
of 23 packages: `objectql` 73 (29 files), `rest` 18 (7), `runtime` 7
(4), `plugin-security` 6 (5), `cli` 6 (3), `lint` 6 (4),
`service-datasource` 6 (3), `driver-sql` 4 (4), `platform-objects` 4
(2), `plugin-approvals` 3 (2), `service-automation` 3 (2),
`driver-mongodb` 3 (1), `plugin-auth` 3 (1), `service-analytics` 3 (3),
`metadata-core` 2 (1), `plugin-hono-server` 2 (1), and one each in
`client`, `triggers`, `core`, `metadata-protocol`, `qa/dogfood`, `types`
and `driver-memory`.
- **Two spec test files outside `src/`** carry same-id titles:
`packages/spec/scripts/file-description.test.ts:66` and
`packages/spec/scripts/format-type.test.ts:85`. They are outside class
(e) as ruled ("the test strings shipped under `src/`").
- **Numeric delivery fields:**
`bulk-write-hook-conformance.test.ts:115-116` and `:129-130` assert
`engineDeliveryIssue: 5038` / `5574`, numbers in the source contract
table. They are not strings, the gate's pattern cannot see them, and
they are not this card's share.
- **Code comments still carry ids** in these files and their sources,
for example the header of `analytics-strictness-batchd.test.ts` and the
`SINGLE_RECORD_WRITE_ONLY` comments in
`api-methods-batch-conformance.test.ts`. Comments are not this card's
share, and none is touched here.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ers 400 SMS_SERVICE_REQUIRED instead of a bare 500 (objectstack-ai#21858)

Fixes objectstack-ai#21793
Clause-②: yes (widening)

## What changed

`POST /api/v1/auth/phone-number/send-otp` on a deployment with phone
sign-in on but no SMS service that can deliver a code (none wired, or
only the log transport in production) answered **500 with an empty
body**. It now answers **400** with `{ "code": "SMS_SERVICE_REQUIRED",
"message": "..." }`.

- `packages/plugins/plugin-auth/src/auth-manager.ts`: the no-provider
branch of `deliverPhoneOtp` throws better-auth's
`APIError('BAD_REQUEST', { message, code: 'SMS_SERVICE_REQUIRED' })`,
the same construction the sibling refusals in this file use (for example
`PASSWORD_POLICY_VIOLATION`). The message names the missing SMS delivery
service and where an administrator configures it (Setup, Settings, SMS
Delivery), and points the user at phone and password sign-in. It never
carries the one-time code. The quota branch and the delivery path are
unchanged. Four doc comments that said `NOT_SUPPORTED` now name the new
answer.
- `packages/spec/src/api/error-code-ledger.zod.ts`:
`SMS_SERVICE_REQUIRED` is registered for `@objectstack/plugin-auth`,
beside `EMAIL_SERVICE_REQUIRED`. The reference pages
`content/docs/references/api/{contract,error-code-ledger}.mdx` are
regenerated (`gen:docs`, as `check:generated` named).
- `content/docs/permissions/authentication.mdx`: the two passages that
said "fails loudly with `NOT_SUPPORTED`" now state the 400 and the code.
The first also says that `request-password-reset` keeps answering
`{status:true}`.
- `docs/qa/platform-checklist/areas/identity-auth.json`:
`identity-auth.auth-method-matrix` (revision 5) names the shipped
refusal in clause 4, its negative, step 4, the fixtures row and the
variant. A bare 500 is now named a FAIL, and the clause cites the door
pin.
- Changeset: `@objectstack/spec` **minor** (the ledger accepts one more
value), `@objectstack/plugin-auth` **patch** (the bug fix).

## H2: which branch, and the measurement that decided it

**Branch (b): a new registered code.** No registered code honestly names
"phone OTP needs an SMS delivery service" (measured at merge base
`6fb71152c`):

- `@objectstack/plugin-auth`'s row: `EMAIL_SERVICE_REQUIRED` names the
email service. `PHONE_NOT_ENABLED` means the phone plugin is off, which
is not this case. `INVITE_SMS_FAILED` is a failed invitation send. No
other row is about SMS.
- The standard catalog has no SMS member. `SERVICE_UNAVAILABLE` and
`NOT_IMPLEMENTED` misname the cause and are 5xx.
- No other package's row names SMS delivery.

The spelling `SMS_SERVICE_REQUIRED` already exists in this package:
`sendPhoneInviteSms` throws it as a plain-`Error` prefix for the same
condition. So one name now covers one condition. It passes the objectstack-ai#8211
synonym rule, because the token `SMS` is in no standard member. The
status is **400**, the status of the email sibling
(`admin-import-users.ts` answers `EMAIL_SERVICE_REQUIRED` with
`fail(400, ...)`).

## Mechanism hypotheses, measured

All door readings use the real `AuthManager.handleRequest` over the
installed better-auth 1.7.3 / better-call 1.4.0.

- **H1, confirmed.** Before the fix, the no-provider branch answered
`500`, no `content-type`, body `""` (measured under the ablation below).
The quota branch answered `429`, `application/json`, `{"message":"Too
many verification codes requested. Please try again later."}`, with no
`code` field. After the fix, no-provider answers `400`,
`application/json`, `{"message":"Phone verification codes are
unavailable: ...","code":"SMS_SERVICE_REQUIRED"}`. The quota answer is
unchanged.
- **H2:** see above.
- **H3:** 400, from the email sibling.
- **H4, confirmed** at objectui `9dfaca65` (the `.objectui-sha` pin).
`packages/auth/src/createAuthClient.ts` `postPhoneNumberEndpoint` reads
the top-level `payload.code` and `payload.message` of this vendor-shaped
body. `LoginForm.handleSendOtp` shows `errorMessages[code]` if one is
mapped, and the message otherwise. Before the fix the payload was
`null`, so the user saw "Auth request failed with status 500".

## Tests

- New
`packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts`
is the door pin. It sends a real request through a real better-auth
pipeline and a pinned memory engine. It checks:
- With no SMS service: `400`, `code === 'SMS_SERVICE_REQUIRED'`, and
`ErrorCode.safeParse(code)` succeeds, so the code is registered.
  - The refusal text never contains the OTP that better-auth stored.
- With `NODE_ENV=production` and a log-only transport: the same 400 and
code, and nothing is sent.
- `request-password-reset` for a registered number still answers `200
{status:true}`. A pass-through spy proves the route reached the refusing
send.
  - Outside production, a log-only transport still delivers.
- In `auth-manager.test.ts`, the old `rejects.toThrow(/NOT_SUPPORTED/)`
case became two cases on the error object: `isAPIError`,
`BAD_REQUEST`/400, `body.code`, and no code in the message.
- **Ablation.** The plain `Error` was put back through
`scripts/ablation-replace.mjs` (mutation landed: anchor 1 to 0, blob
`9d24bb3f` to `9c6b1b90`). Result: **5 failed / 282 passed**. That is 3
door cases (`expected 500 to be 400`, and the deliver spy rejects with
`Error: NOT_SUPPORTED...` instead of the 400 shape) and 2 unit cases
(`isAPIError` false, `statusCode` undefined). The tool's restore leg
proved blob == HEAD `9d24bb3f` and an empty `git diff HEAD`. The first
attempt was refused by the tool before any test ran, because the
replacement text contained the anchor. It was redone with a whole-block
anchor.
- `pnpm --filter @objectstack/plugin-auth exec vitest run`: 120 files,
2515 passed, 10 skipped (at `b37edd67`). Typecheck exit 0. After the
last test-file edit, the two changed files were re-run at `3e8ab846`:
286 passed.
- `pnpm --filter @objectstack/spec exec vitest run`: 668 files, 19286
passed, 1 todo (at `b37edd67`). Typecheck exit 0 (at `3e8ab846`).
- `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up
to date, after a spec rebuild on the final merge `c6b17163`.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `c6b17163` derived 124 commands. All 124
were run on that head and exited 0. `--ran` with the exit codes
recorded: 124 derived, 124 run, 0 NOT-MEASURED, 0 UNRUN. On the first
pass (at `b37edd67`), `check:engine-double-contract` and
`check:objectql-double-limit` were red on the new test's engine double.
The double now applies `limit`/`offset` by presence, and `--write`
recorded the pinned double (additions only). The local scope is the
targeted set above; the rest of the farm is CI's.

## Riding comments (domain:spec pointer 5989650462)

These are comment-only edits in `error-code-ledger.zod.ts`. No code,
status or owner moved. Each claim was checked against the tree:

1. `DRIVER_UNAVAILABLE` (`@objectstack/cloud-connection`):
**rewritten.** Measured: the only emitter is the purge-sample-data door
(`marketplace-install-local-plugin.ts`, the `!ql || !metadata` branch,
500, introduced by objectstack-ai#21773). So the comment now states that condition,
rather than adding it as an "also".
2. `RESEED_SKIPPED`: **not edited. The claim does not hold on this
tree.** Since objectstack-ai#21780 (`e09f1aca`), a walled session with no active
organization gets `403 PERMISSION_DENIED` on the purge (and on the
reseed), through `NO_ACTIVE_ORGANIZATION_CODE`. `RESEED_SKIPPED` is now
emitted only by the reseed, for its other declines. The existing comment
("reseed declined to run; message carries why") is accurate.
3. `OS_PROTOCOL_INCOMPATIBLE` (`@objectstack/metadata-core`):
**rewritten** to name both doors. `POST /api/v1/packages`
(`runtime/src/domains/packages.ts`) and, since objectstack-ai#21805, `POST
/api/v1/marketplace/install-local` both answer through the shared
`protocolIncompatibleAnswer`.

## Acceptance notes

- The quota branch answers 429 with **no** `code` in its body (measured
above). This is deliberate: `auth-manager.test.ts` pins `bodyCode:
undefined`, so both walls look the same from outside. It is untouched
here.
- `sendPhoneInviteSms` still throws a plain
`Error('SMS_SERVICE_REQUIRED: ...')` when no SMS service is wired. No
door reaches that throw, because its one caller gates on
`isSmsServiceAvailable()` first. It is the delivery path, so it was out
of scope and is unchanged.
- Files outside the expected surface, all made false or required by this
change: the checklist item, the two regenerated reference pages, and
`scripts/engine-double-contract.pinned.json`. That last one records the
new pinned test double, written by `check-engine-double-contract.mjs
--write`, additions only.

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

---------

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

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants