Skip to content

fix(runtime): the @objectstack/hono catch-all's PUT /meta honours If-Match, If-None-Match and ?mode=draft - #22206

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22141-hono-meta-write-preconditions
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22141-hono-meta-write-preconditions

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22141
Clause-②: yes

What this changes

Behind @objectstack/hono's ${prefix}/* catch-all, PUT /meta/:type/:name is answered by the runtime dispatcher's /meta domain (handleMetadataRequest). That branch handed saveMetaItem no parentVersion and no mode. So through the catch-all a stale If-Match wrote (200, not 409), If-None-Match: * over an existing row wrote, and a ?mode=draft save landed on the ACTIVE row. A draft went live without a publish, and the client read a 200.

  • One mapping, two doors. RestServer's private precondition parser moves to @objectstack/rest as metaSaveRequestOptions (packages/rest/src/meta-save-request.ts). It now also reads ?mode=draft. RestServer's PUT door and the dispatcher's PUT branch both call it, and spread its request (parentVersion and mode, each present only when asked) into saveMetaItem. It reads a Fetch Headers (get) or a plain header record, so it takes the raw Request the catch-all hands dispatch() and the record the plugin-hono-server adapter hands RestServer.
  • Dispatcher refusals. A pin that can never be honoured (both headers, or an If-None-Match other than *) is 400 VALIDATION_ERROR, after the capability gate and before anything is written, as on RestServer. The dispatcher alone has a fallback writer (metadata.saveItem(type, name, item), used when the protocol has no saveMetaItem). It cannot carry a pin or a lifecycle, so a save that asks for one is refused 501 NOT_IMPLEMENTED there instead of written unguarded or active.
  • RestServer answers are unchanged. One ordering moved: its multiplicity guard (refuseRepeatedQueryParams for force / package / mode) now runs just before the shared read, because the guard unwraps a single-element ?mode array in place and the mapping must see the unwrapped string. A request that carries both a malformed pin and a repeated parameter now gets the repeated-parameter 400 first. Both are 400 VALIDATION_ERROR.
  • Widened public surface (Clause-②: yes). @objectstack/rest's package entry now exports metaSaveRequestOptions and the types MetaSaveRequestHttp, MetaSaveRequestMembers and MetaSaveRequestOptions. That widens its public surface, so its changeset is minor with Clause-②: yes (widening), the shape the read-side twin of this parity declared when it published createMetaItemReadGate. @objectstack/runtime's public surface does not change: its changeset stays patch with Clause-②: no.
  • @objectstack/hono itself is unchanged: the catch-all already hands dispatch() { request: c.req.raw }, so the headers were on context.request all along (H3). The fix ships in @objectstack/runtime (patch) and @objectstack/rest (minor).

Readings (on origin/main 6ed0c0f3, this branch's base)

  • H1, reach. In this repository objectstack serve / os dev compose createRestApiPlugin and createDispatcherPlugin. The dispatcher plugin mounts no /meta route (its explicit mounts are /notifications*, /keys, /analytics/*, /i18n/*, /health, /ready, /discovery, plus AI routes), so /meta writes there are RestServer's. packages/adapters/ now holds only hono, and createHonoApp is called by no app, example or package outside tests. Whether the hosted runtime's PUT /meta reaches this catch-all, and on which cloud pin: NOT MEASURED from this session (objectstack-ai/cloud is not readable here). The grade is the seat's.

  • H2, reproduced on main, through the real createHonoApp. A scratch probe (not committed) mounted createHonoApp({ kernel, prefix: '/api/v1', cors: false }) from this checkout's adapter source over a LiteKernel carrying a real ObjectStackProtocolImplementation on ObjectQL + better-sqlite3 :memory:, with identity stubbed to manage_metadata. Requests went through app.request. Same probe, main's meta.ts then this branch's:

    request through the catch-all main this branch
    PUT /api/v1/meta/view/case_grid (v1, then v2) 200, 200 200, 200
    PUT with If-Match = v1's token (stale) 200, active row now v3 stale 409 METADATA_CONFLICT, active row v2
    PUT with If-None-Match: * over an existing row 200, active row now second 409 METADATA_CONFLICT, active row first
    PUT ?mode=draft 200, receipt state: 'active', active row staged, no draft row 200, receipt state: 'draft', active row v2, draft row staged
  • H3, where. Confirmed: handleMetadataRequest's PUT branch read neither headers nor mode. The catch-all's dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix) already carries the Fetch Request, so context.request.headers.get(...) reaches them. No adapter change is needed.

  • H4, one mapping. Dependency direction measured: @objectstack/runtime depends on @objectstack/rest (dependencies), and rest does not depend on runtime. So the shared parser lives in @objectstack/rest, beside the /meta read chain the runtime already imports from there. No packages/spec edit.

  • H5, every /meta write verb. Measured on both doors (catch-all rows driven through dispatch() with the catch-all's arguments, every header and parameter sent):

    write verb parameter RestServer catch-all before catch-all after
    PUT /meta/:type/:name If-Match → parentVersion; stale → 409 METADATA_CONFLICT dropped: stale token writes, 200 as RestServer
    If-None-Match: * → parentVersion: null; over a row → 409. Non-* or beside If-Match → 400 VALIDATION_ERROR dropped: writes, 200 as RestServer
    ?mode=draft → mode: 'draft' dropped: lands active as RestServer
    DELETE /meta/:type/:name (reset) If-Match → parentVersion; ?state=draft → state; If-None-Match not read as listed verb not served: 405 METHOD_NOT_ALLOWED (Allow: GET, HEAD, PUT), no protocol call unchanged
    POST /meta/:type/:name/publish none of the three is read — route not served: 404 ROUTE_NOT_FOUND, no protocol call unchanged
    POST /meta/:type/:name/rollback none of the three is read — 404 ROUTE_NOT_FOUND, no protocol call unchanged
    POST /meta/_migrate-stored none of the three is read — served; reads none of the three unchanged

    The three verbs the catch-all does not serve answer loudly and write nothing. The comment on the 405 branch records that mounting a real DELETE there "needs its own card". RestServer's rows where a parameter is not read stay as they are (not widened).

  • H6, same answer. Partly holds. Same status, same code, same refusal sentence, and the draft receipt says state: 'draft' on both doors. Not byte-identical envelopes: each transport answers in its own dialect, and the spec declares MetadataConflictErrorSchema as "the REST door's flat ADR-0112 dialect". RestServer's 409 is { error: SENTENCE, code: 'METADATA_CONFLICT', currentVersion: TOKEN }. The catch-all's is { success: false, error: { code: 'METADATA_CONFLICT', message: SENTENCE, httpStatus: 409 } } (measured through the real createHonoApp). currentVersion is not carried as data on the catch-all. That is an open question for the seat (see the report), not decided here.

  • H7, neighbour. rest-server.ts's ?package reads (the PUT door's packageRaw / packageId lines and the publish door's) are untouched. Edits there are the import, the removed private parser, and the PUT door's precondition / guard / spread lines.

Pins (packages/runtime/src/domains/meta-save-preconditions-parity.test.ts)

Each door over its own real store (better-sqlite3 :memory:, the real sys_metadata* objects, a real ObjectStackProtocolImplementation). The store is read after every write. The RestServer leg calls its registered PUT handler. The catch-all leg repeats the catch-all's four statements over a real Fetch Request (path below the prefix, JSON body, query flattened from the URL, the raw Request as context.request) into the real HttpDispatcher.dispatch(). It cannot import createHonoApp: this package cannot depend on @objectstack/hono (that package depends on it), and packages/adapters/hono's suite aliases @objectstack/runtime to a stub. The H2 probe above is the real-createHonoApp reading.

Per door: control (an unguarded save writes the active row, last writer wins); a stale If-Match → 409 METADATA_CONFLICT, nothing written, and the current token still saves; If-None-Match: * writes the first row and over an existing row → 409, nothing written; ?mode=draft stages a draft and leaves the active row untouched; both unhonourable pins → 400 VALIDATION_ERROR, nothing written. Plus one side-by-side row (same status, code and token-masked sentence on both doors), and the dispatcher's fallback writer (three asks → 501 and saveItem never called; control: an unguarded save still reaches it).

Reverse verification and ablations (fix committed first; each leg restored from HEAD and proven by blob hash and an empty git diff HEAD; the subject resolves by relative source import and @objectstack/rest by the runtime vitest alias to source, so no dist/ leg applies):

leg result which rows went red
this branch 15 passed —
main's meta.ts in the tree 8 failed, 7 passed all on the catch-all side (4 case rows, the side-by-side row, 3 fallback rows). RestServer's 5 rows and both controls green.
ablation A: catch-all reads headers: undefined 6 failed, 9 passed stale token, If-None-Match: *, the 400 row, the side-by-side row, 2 fallback header rows. RestServer green; the catch-all's draft row green.
ablation B: catch-all reads query: undefined 2 failed, 13 passed the catch-all's draft row and the fallback draft row. RestServer green.

Ablations went through node scripts/ablation-replace.mjs (anchor hit x1 → x0, blob e90328952e70 → 18cb795a909f / 88f9509a61df, restored to e90328952e70 == HEAD, git diff HEAD empty).

Verification

All at this branch's head bba46774 (git rev-parse --short HEAD), base 6ed0c0f3. Heavy runs went through scripts/pm/os-verify-lock.sh.

  • Build. pnpm --filter '@objectstack/runtime^...' build (dependency closure), then @objectstack/rest and @objectstack/runtime rebuilt at head: exit 0. dist/ checked to carry metaSaveRequestOptions.
  • Typecheck. pnpm --filter @objectstack/rest typecheck: exit 0. pnpm --filter @objectstack/runtime typecheck: exit 0. The test layer printed check:test-typecheck: OK — 27 file(s) / 190 error(s) / 68 pinned signature(s) held, ledger unchanged. The new test file is in that program: it was refused once, for one error, before the fix in bba46774.
  • Unit tiers. pnpm --filter @objectstack/rest exec vitest run --project local: 261 files passed, 4929 passed, 326 skipped. pnpm --filter @objectstack/runtime exec vitest run --project local: 335 files passed, 4736 passed, 19 skipped. The repo projects (test:repo) were not run locally; they are left to CI.
  • Gates. The order's 64 commands plus the full pnpm lint, each exit recorded. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands re-derived at head gives the same 64 (8 paths, 653 changed lines, under the 5000 threshold). --ran reconciliation: 64 derived famil(ies) accounted for — 63 run, 1 NOT-MEASURED, 0 unrun. 63 exit 0, and pnpm lint (eslint . --no-inline-config, whole repo) exit 0 with no findings.
    • NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET. It reads every package's dist/, and 38 packages are unbuilt in this worktree. Declared narrowing in its place: the require entries of the two packages whose shipped code changed load at head. require('packages/rest/dist/index.cjs') gives 34 exports including metaSaveRequestOptions, and require('packages/runtime/dist/index.cjs') gives 312 exports. CI runs the full gate.
  • Upstream. origin/main is 6 commits past the base (13aea189). None of them touches meta.ts, rest-server.ts, rest/src/index.ts, query-multiplicity.ts, http-dispatcher.ts or metadata-protocol/src/protocol.ts, so no merge of main was taken.
  • Round 1 head 6d5f6a5f. Only .changeset/22141-rest-meta-save-request-options.md changed (patch → minor, Clause-②: no → Clause-②: yes (widening)), so the code verification above stands. dispatch-gates --commands for that path derives 20 gates, and all 20 exit 0 at 6d5f6a5f (--ran: 20 run, 0 NOT-MEASURED, 0 UNRUN). The branch union is unchanged (the same 64). Also exit 0: check:type-check-coverage, check:type-check-debt, and check-changeset-no-major.mjs --base origin/main --event over this body, whose level axis reads Clause-②: yes and finds @objectstack/rest: minor.

Acceptance notes

Written by session session_01RWZbGvPFcRKvUqASZtunCU. Round 1 changed only the Clause-② declaration (the @objectstack/rest changeset regrade and this body), with no code change.

claude added 4 commits October 8, 2026 05:31
…h and ?mode=draft

The runtime dispatcher's PUT /meta/:type/:name (the only answer behind the
@objectstack/hono catch-all) handed saveMetaItem no parentVersion and no
mode, so a stale If-Match wrote, If-None-Match: * over an existing row
wrote, and a ?mode=draft save landed active. RestServer's precondition parser
moves to @objectstack/rest's metaSaveRequestOptions, which now also reads
?mode=draft, and both doors call it.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-authored-by: Claude <noreply@anthropic.com>
…If-None-Match and ?mode=draft

RestServer's PUT handler and the dispatcher driven exactly as the
@objectstack/hono catch-all drives it, each over its own real sqlite store:
a stale token and If-None-Match: * over a row are refused 409 and write
nothing, a draft save leaves the active row untouched, an unguarded save is
unchanged, and the dispatcher's fallback writer refuses a pin it cannot carry.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-authored-by: Claude <noreply@anthropic.com>
…r the /meta save preconditions

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-authored-by: Claude <noreply@anthropic.com>
…ledger the moved draft switch

refuseRepeatedQueryParams unwraps one ?mode occurrence encoded as an array,
so the shared mapping must read the query after it, as the inline read did.
The census of rest-server.ts draft switches loses its PUT row because that
switch is now read in meta-save-request.ts. The parity test's unhonourable
header list is typed so tsc accepts it.

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 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime, touching 16 documentable anchor(s).

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0e9371f0c6104922e19b9bdaabc993b4d3dbca61.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 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 — 34 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 0e9371f0c6104922e19b9bdaabc993b4d3dbca61 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0e9371f0c6104922e19b9bdaabc993b4d3dbca61

⚠️ 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 0e9371f0c6104922e19b9bdaabc993b4d3dbca61 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…Options export widens its public surface

Clause-②: yes (widening) for the rest changeset; the runtime changeset stays
patch with Clause-②: no.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

① Derived judgments

Inputs, read 2026-10-08T06:40Z to 06:50Z: card #22141 (body and all six comments: triage 6052589425, claim 6052823060 as amended, reports 6053751704 and 6054009746, REWORK 6053835755, ACCEPT 6054035642), PR #22206 (body, 8-file list, net diff origin/main...6d5f6a5f, +585/−68 = 653 lines), the check-runs on the head, and the precedent #20193 / PR #20236. The PR head is still 6d5f6a5f (draft, base main, mergeable). origin/main is 7d7943dd, seven commits past the base 6ed0c0f3; none touches a file this PR touches or its neighbours (query-multiplicity.ts, http-dispatcher.ts, metadata-protocol/src/protocol.ts, spec/src/api/protocol.zod.ts, the hono adapter).

  • Public surface, @objectstack/rest: a widening, named right. packages/rest/src/index.ts adds one value export (metaSaveRequestOptions) and three type exports (MetaSaveRequestHttp, MetaSaveRequestMembers, MetaSaveRequestOptions) to the package's only subpath (exports: ".", not private). The body is RestServer's former private metaSavePreconditionPin moved verbatim (the three refusal sentences are byte-identical) plus the ?mode=draft predicate that was inline at the PUT door (the same typeof === 'string' && toLowerCase() === 'draft'), with one new reach: a Headers-like get branch beside the record lookup h[lower] ?? h[canonical], which is what lets the catch-all's Fetch Request be read. MetaSaveRequestMembers picks parentVersion and mode off SaveMetaItemRequest; both are declared members of SaveMetaItemRequestSchema (parentVersion string nullable optional, mode enum draft|publish). No packages/spec edit. Dependency direction runtime depends on rest confirmed in both package.json files. Right.
  • Public surface, @objectstack/runtime: none. No export changes, handleMetadataRequest keeps its signature, and the import block already drew from @objectstack/rest. Right.
  • The dispatcher's PUT /meta/:type/:name refusals are a correction, not a narrowing. The declared contract (ADR-0008 §2.5: writes declare the head they expect and the server arbitrates; ADR-0033 §2: a draft write never lands live) and RestServer's own door already answered this route with 409 for a stale If-Match or * over a row, 400 for an unhonourable pin, and a draft row for ?mode=draft. main's meta.ts read none of it (zero occurrences of parentVersion or if-match; draft read only on the draft READ doors). The accept set the diff shrinks is one the contract never granted: a 200 that wrote through a declared refusal is the class-(a) wrong answer the card measured. Codes derive right: deps.error(msg, 400) resolves to standardErrorCodeForHttpStatus(400) = VALIDATION_ERROR; the 409 is the protocol's conflict builder (code = METADATA_CONFLICT, status = 409, a ledger member, kept by resolveThrownHttpError). mode: 'draft' reaches saveMetaItem as it does from RestServer. Clause-②: no on runtime is right, the same shape as the [finding] the runtime dispatcher's /meta item reads apply NO per-caller read gate: through a catch-all host, GET /meta/doc/:name serves a permission-set-gated doc body to a non-holder, and /meta/app/:name serves requiredPermissions-gated entries #20193 ruling that a pull-back to the declared contract is not clause ②.
  • The fallback writer's 501: a bounded in-place refusal, named right. Reached only when protocol.saveMetaItem is absent and a metadata service with saveItem(type, name, item) exists; that writer can carry neither parentVersion nor mode, so the alternatives were the unguarded or active write this card closes, or a refusal. 501 is the code RestServer already answers for a save its protocol cannot take, derived as NOT_IMPLEMENTED; the sentence matches none of looksLikeInternalErrorLeak's limbs, so it reaches the wire. No non-test saveItem implementer exists under packages/*/src. Declared in the runtime changeset. Right. One unpinned precedence edge, nothing to fix: on a no-saveMetaItem host RestServer answers 501 before judging the pin while the dispatcher answers a malformed pin 400 first; both refuse, neither writes.
  • RestServer's answers: unchanged in status, code, wording and body on every request, with the one declared exception. Verified against main: pin read, then resolveMetaWriteActor, then refuseRepeatedQueryParams(force, package, mode) became guard, then metaSaveRequestOptions, then actor. The guard unwraps a one-element ?mode array in place (query[name] = read.value), so the mapping must follow it: the move is forced, not cosmetic. resolveMetaWriteActor has no answer of its own. The spread ...saveOptions.request reproduces the old parentVersion spread and the trailing mode: 'draft' spread exactly (key order in the literal is not observable). req.headers arrives as c.req.header(), a plain lowercased record (plugin-hono-server/src/adapter.ts:1313), so the record branch runs and the header read is byte-identical. The one observable change: a request carrying BOTH a malformed pin and a repeated force, package or mode now gets the repeated-parameter 400 sentence instead of the pin's. Both are 400 VALIDATION_ERROR and the PR body declares it. The rest changeset's "Unchanged: RestServer's answers" is true of status, code and each sentence's wording and silent on that precedence; acceptable. Right.
  • @objectstack/hono: unchanged, correctly. packages/adapters/hono/src/index.ts:739 already hands dispatch() { request: c.req.raw }; the test's catchAll repeats its four statements (sub-path substring, JSON body with catch to {}, searchParams flatten, the raw Request) statement for statement. Nothing publishes from hono, so no changeset is owed there despite triage's line; the deviation is right.
  • No anchor breaks of the fix(runtime,rest): the dispatcher /meta item reads ask the per-caller read gate RestServer asks (#20193) #20236 kind. Every liveness evidence citation into rest-server.ts is a #symbol anchor (registerMetadataEndpointsInner, findPublicFormView, resolveFormBySlug, and so on), none of them the moved function; the rest-server.ts:1716 in view.json is prose in a note, which the gate does not read. scripts/adr-anchors/packages__rest__src__rest-server.ts.json anchors ADR-0045 filterAppForUser, untouched. meta-draft-read-door-census.test.ts drops its PUT ?mode=draft row because that literal left the file, and its header names the holders.
  • PR-body readings checked against the tree, all true: packages/adapters/ holds only hono; no createHonoApp( caller outside tests (one docblock mention in runtime/src/domains/auth.ts); the dispatcher's /meta item door answers DELETE 405 with Allow: GET, HEAD, PUT (METADATA_ITEM_METHODS, meta.ts:229 and :1541–:1564, comment "needs its own card") and unrouted tails 404; RestServer's DELETE /meta door reads If-Match on its own (:7335) plus ?state=, as the H5 table says; the other two if-match reads (:9148, :9230) are the /data update and delete doors, not /meta. Round-1 delta bba46774..6d5f6a5f is one file, +2/−2.
  • Residuals, named not failed: the dispatcher judges no query multiplicity, so a repeated ?mode through the catch-all reads as its last value where RestServer refuses 400 (pre-existing for every dispatcher query key, declared in Acceptance notes); the ?package=all binding is finding(rest): ?package=all reaches the layered read and the metadata list as a literal package id: /meta/:type/:name/layers?package=all answers 404 and GET /meta/:type?package=all answers [] for an item stored in a package #22188 (open, finding); H1 cloud reach is NOT MEASURED, so the p1 grade stays triage's.

② Semver level

③ Boundary flags

The dev's flags (report 6053751704 deviations, report 6054009746 deviations) and the one open_questions entry:

  1. No hono changeset: answered, correct (①).
  2. The committed pin drives HttpDispatcher.dispatch() with the catch-all's arguments, not createHonoApp, and the real host is an uncommitted probe: answered with a correction, escalated to the seat. The two facts named are true (runtime cannot depend on hono; adapters/hono's vitest aliases @objectstack/runtime to src/__mocks__/runtime.ts), but the repo already holds the home built for this: packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts composes the real createHonoApp over a LiteKernel for the read-side twin. Triage's direction here ("one conformance test runs both doors over the same three cases") is met on the real dispatcher, the only thing the catch-all calls, so this does not fail the record. The seat decides whether a composed-host row of the write pins is added in packages/qa/http-conformance (this PR or a follow-up card) so the adapter's one-line hand-off is held by a test rather than a quoted probe.
  3. File surface beyond the claim's three files: answered, forced by the move; the claim was amended in place (6052823060) to name them.
  4. Bounded in-place 501 on the fallback: answered, right (①).
  5. RestServer refusal order: answered, forced and declared (①).
  6. H6 partly falsified, and open_questions[0] (currentVersion on the catch-all's 409): answered, C holds. MetadataConflictErrorSchema's docblock scopes itself to "the REST door's flat ADR-0112 dialect" and to the four RestServer /meta item doors, so no declared contract binds the dispatcher's envelope; B (an undeclared error.details member) would mint a dialect ADR-0112 forbids; A is a packages/spec change for domain:spec. The conflict builder already sets currentVersion on the thrown error, so A is cheap when reach is shown. The reopen condition needs a carrier: the seat should give it a card rather than an Acceptance note alone.
  7. The pnpm lint order-versus-os-dev.md tension, the model-free commit trailers, the round-1 single PR-body PATCH with no footer appended, and the 44 branch-union gates carried from bba46774: answered, process with no contract effect; CI re-ran every gate on the head.
  8. out_of_scope_findings[0], ?package=all: routed to finding(rest): ?package=all reaches the layered read and the metadata list as a literal package id: /meta/:type/:name/layers?package=all answers 404 and GET /meta/:type?package=all answers [] for an item stored in a package #22188, open.

Check-runs on 6d5f6a5f, read 2026-10-08T06:50Z: 35 success, 8 skipped, 1 in progress (Test Core (1/6), started 06:29:57Z), 0 failure. Lint & Repo Gates, Check Changeset (three runs), all four Type Check jobs, Build Core and Test Core 2 through 6 are green. Not fully green at this reading; the landing waits on that shard, which this record does not pre-empt.

Implemented-by: claude/issue-22141-hono-meta-write-preconditions
Reviewed-by: session_01RWZbGvPFcRKvUqASZtunCU

VERDICT: PASS

Resolves the one conflict, in packages/rest/src/rest-server.ts, with PR
22185. This branch removes the private metaSavePreconditionPin and its
docblock (moved to meta-save-request.ts as metaSaveRequestOptions); main
keeps that function unchanged and adds metaItemPackageBinding with its
docblock directly below it. Both intents are kept: metaSavePreconditionPin
stays removed, and metaItemPackageBinding with its docblock stays
unchanged. No other edit rides this commit.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 3aa0c7b Oct 8, 2026
35 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22141-hono-meta-write-preconditions branch October 8, 2026 09:34
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…Match, If-None-Match and ?mode=draft (objectstack-ai#22341)

Fixes objectstack-ai#22221
Clause-②: no

## What this adds

One test file, no production code:
`packages/qa/http-conformance/src/hono-meta-save-preconditions.conformance.test.ts`.
It composes the real `createHonoApp` over a `LiteKernel` and sends every
request through `app.request(...)`. So the `@objectstack/hono`
catch-all's hand-off to `dispatch()`
(`packages/adapters/hono/src/index.ts:739`) is in the path, which no
test held before. Four rows. Each asserts the status, the envelope, and
the stored rows, read back from `sys_metadata` after the request:

| row | request through the catch-all | asserted |
|:--|:--|:--|
| control | `PUT /api/v1/meta/view/case_grid` twice, no header | `200`,
`success: true`, receipts `state: 'active'`; active row `v2`, no draft
row |
| stale `If-Match` | `PUT` with `If-Match` set to the first receipt's
`version` | `409`, `success: false`, `error.code` `METADATA_CONFLICT`;
active row still `v2`; the current token then saves `v3` |
| `If-None-Match: *` | a first write with it (`200`), then the same over
the existing row | `409`, `success: false`, `METADATA_CONFLICT`; active
row still `first` |
| `?mode=draft` | `PUT ?mode=draft` over an active row | `200`, receipt
`state: 'draft'`; active row unchanged, draft row `staged`; `GET
...?state=draft` answers `staged`, and the plain `GET` still answers the
active row |

**File choice (the seat's cut): a new file beside the read-side twin,
not rows added to it.** `hono-meta-item-read-gate.conformance.test.ts`
is about the read door, and it answers from a stub protocol (a `vi.fn`
over a plain object) that keeps no version and no draft. The write rows
need a real store. So they live in their own file, and each file stays
about one door.

There is no `package.json`, `turbo.json` or
`scripts/cross-package-test-inputs.mjs` change: every import is a
dependency this package already declares (H5 below). There is no
changeset: `@objectstack/http-conformance` is `private: true`, and the
diff is one test file.

## Readings (the PM's H1 to H5, measured on `origin/main` `28bff18d`)

- **H1, the line under test: holds.** The read path:
- the catch-all calls `dispatcher.dispatch(method, subPath, body,
queryParams, { request: c.req.raw }, prefix)`
(`packages/adapters/hono/src/index.ts:739`);
- that reaches `handleMetadataRequest`'s `PUT` branch, which calls
`metaSaveRequestOptions({ headers: _context.request?.headers, query })`
(`packages/runtime/src/domains/meta.ts:1366`; the function is in
`packages/rest/src/meta-save-request.ts`);
  - the answer is spread into `protocol.saveMetaItem`.

  The function that reads each one:
- `If-Match` and `If-None-Match`: `metaSaveRequestOptions`, through its
`requestHeader()` helper. That helper calls `Headers.get` on the raw
`Request` the catch-all hands on as `context.request`.
- `?mode=draft`: also `metaSaveRequestOptions`, but from `http.query`.
That is the catch-all's `queryParams` argument (flattened from the URL),
**not** the `Request`. See H4.
- **H2, the harness: the twin's shape is reused, its store is not.**
Same shape: `LiteKernel`, then `createHonoApp({ kernel, prefix:
'/api/v1', cors: false })`, then `app.request`. Same single stubbed
seam: `HttpDispatcher.prototype.timedResolveExecutionContext`. Two
differences, both deliberate:
- **Store.** The twin's stub protocol cannot refuse or stage a save.
What I added: `ObjectQLPlugin`'s built-in assembly (`registerProtocol`
at its default). It registers a real `ObjectStackProtocolImplementation`
and the `sys_metadata*` objects. It runs over `SqliteWasmDriver({
filename: ':memory:' })`, handed in as a `driver.memory` service. The
boot logs `Schema sync complete {"synced":5}`. Both packages were
already in this package's `devDependencies`, because the integration
suite boots them.
- **Identity.** The twin keys the principal on a request header. Here
the stub answers one author (`manage_metadata`) for every request, so no
row depends on the request to know who the caller is. Otherwise,
ablating the `{ request }` hand-off would also strip the caller. Every
row, the control included, would then go red for the wrong reason.
- **H3, the four rows through the real app: hold.** See the table above.
4 passed.
- **H4, the ablation: partly falsified.** Passing `{}` turns only the
two header rows red. The `?mode=draft` row stays green under it, because
`mode` rides the `queryParams` argument of the same call, not
`context.request`. So the draft row gets its own ablation of the
hand-off: the query argument. The control stayed green in every leg.
Table below.
- **H5, cross-package test inputs: nothing to add.** `pnpm
check:cross-package-test-inputs` exits 0: `OK: 30 package(s) read
outside themselves, all declared ... 2854 test file(s) import a
workspace sibling by bare specifier over 378 package pair(s), every one
declared.` The new file imports only by bare specifier, over pairs that
are already declared.

## Ablations (one per row, never committed)

The subject is `packages/adapters/hono/src/index.ts`, HEAD blob
`8c32e89e8fc2`. `vitest.config.ts`'s anchored alias points
`@objectstack/hono` at that SOURCE, so no `dist/` leg applies. Each leg
went red while `packages/adapters/hono/dist` was untouched, which proves
the source is what runs.

How each leg ran:
- through `node scripts/ablation-replace.mjs` in WRAP mode, under
`os-verify-lock.sh`;
- the anchor hit x1 before and x0 after, and the mutation was counted on
disk;
- the file was then restored, proven by its blob (`8c32e89e8fc2`, equal
to HEAD) and an empty `git diff HEAD`.

I wrote down the expected direction of each leg before running it. Each
leg landed as predicted.

| leg | mutation at `:739` | control | stale `If-Match` |
`If-None-Match: *` | `?mode=draft` |
|:--|:--|:--|:--|:--|:--|
| head | none | green | green | green | green |
| A | `{ request: c.req.raw }` → `{}` (blob `3900c478d42e`) | green |
**red**: `200, success: true` where `409` was expected | **red**: `200`
where `409` was expected | green |
| A' | the same argument → `{ request: new Request(c.req.raw.url, {
method: c.req.method }) }`, a `Request` with no headers (blob
`effd7d0f0b20`) | green | **red** | **red** | green |
| B | `queryParams` → `{}` in the same call (blob `b64245cd76d5`) |
green | green | green | **red**: receipt `state: 'active'` where
`'draft'` was expected |

Each red reproduces the defect objectstack-ai#22141 described, on the wire: a guarded
save was answered `200` and written, or a draft went live.

## Tier

`Test Core`, on every PR. `ci.yml`'s `Test Core (N/6)` shards run `pnpm
turbo run test` over the affected packages. This package's `test` script
is a plain `vitest run` (one config, no project split), so the new file
runs whenever `@objectstack/http-conformance` is affected. `Test Core`
is one of the seven required contexts.

## Verification (head `e66efff7`, base `28bff18d`)

- **Build.** `pnpm --workspace-concurrency=2 --filter
'@objectstack/http-conformance^...' build` (the dependency closure):
exit 0. A grep confirms that `@objectstack/runtime`'s `dist/` carries
`metaSaveRequestOptions({ headers: _context.request?.headers, query })`.
- **Tests.** `pnpm --filter @objectstack/http-conformance test`: `Test
Files 9 passed (9)`, `Tests 106 passed (106)`.
- **Typecheck.** `pnpm --filter @objectstack/http-conformance
typecheck`: exit 0, `check:test-typecheck: OK ... 3 file(s) / 27
error(s) / 10 pinned signature(s) held`, ledger unchanged. The new file
is in that program: `tsc --listFiles -p tsconfig.test.json` lists 9 of
the package's 9 test files, the new one included. A file the ledger does
not list may carry no error.
- **Gates.** I ran the order's 71 commands and recorded each exit code
before any pipe: 70 exited 0.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`, re-derived at head, gives 53 commands (1 path, 206 changed
lines), a subset of the 71.
- The `--ran` reconciliation: `✓ dispatch-gates --ran: 53 derived
famil(ies) accounted for — 52 run, 1 NOT-MEASURED`, 0 unrun.
- **NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: exit 3,
`PREREQUISITE NOT MET`.** 34 packages are unbuilt in this worktree. This
diff adds one test file to a private package that has no `build` script,
so it emits nothing that gate reads. CI runs the gate in full.
- **Lint.** `pnpm lint` (`eslint . --no-inline-config`, the whole repo)
at `e66efff7`: exit 0, no findings.
- **Upstream.** `origin/main` is 1 commit past the base (`e36ee535`). It
touches 10 files under `service-storage`, `plugins/organizations` and
`qa/dogfood`, none of them in this suite's dependency closure, so I did
not merge `main`.

## Acceptance notes

- **H4's reading was half right, and the missing half matters to anyone
who reads the catch-all.** The `{ request: c.req.raw }` argument carries
the two precondition headers. The `queryParams` argument carries the
lifecycle. A row now goes red if either one is lost.
- **Boot noise, not a finding.** Each boot prints two `[sql-driver]
DATABASE_ERROR` lines (`no such table: sys_setting`, `no such table:
_objectstack_sequences`) at `kernel:ready`. They come from
platform-migration probes against tables this minimal composition does
not provision. They print before the first request, and no row depends
on them.
- The catch-all's `409` still carries no `currentVersion` as data (PR
objectstack-ai#22206's open question H6). These rows assert only `status`, `success`
and `error.code`, so they neither pin nor block any answer to that
question.

Written by session `session_01RWZbGvPFcRKvUqASZtunCU`.

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

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants