Skip to content

[finding] POST /meta/:type/:name/publish is a served REST route with NO spec declaration — the #5745 "declared = returned" discipline covers only the save door #7294

Description

@os-zhuang

Observation-class finding, measured while scoping #4717 on origin/main @ ea8e8490f. Nothing a user hits today — the route works and returns a sensible body. Filing plainly and letting triage grade it.

What was measured

publishMetaItem is served over REST:

  • packages/rest/src/rest-server.ts:5468 registers POST {metaPath}/:type/:name/publish, and :5488 awaits publishMetaItem(...) and hands the result to res.json().
  • Its success return is built at packages/metadata-protocol/src/protocol.ts:9771-9798 and carries success / version / seq / message, plus the conditional seedApplied / materializeApplied / projectionApplied.

But the string PublishMetaItem appears nowhere under packages/spec/src/:

grep -rn "PublishMetaItem" packages/spec/src/     # zero hits

And the endpoint itself is not in the REST declaration — packages/spec/src/api/plugin-rest-api.zod.ts declares 6 endpoints with category: 'metadata', and none of them has handler: 'publishMetaItem' or a /publish path (grep -n publish on that file: zero hits).

Why this is the same class as #5745, one door over

#5745 closed exactly this gap for the sibling write door, and its own prose states the discipline it was establishing. SaveMetaItemResponseSchema (packages/spec/src/api/protocol.zod.ts:456) now declares the FULL save body, the endpoint declares responseSchema: 'SaveMetaItemResponseSchema' (plugin-rest-api.zod.ts:824), and a producer-side conformance gate keeps the two in step — packages/objectql/src/save-meta-response-conformance.test.ts, whose header reads:

a future field added to the response, or an existing one dropped, turns this red instead of silently vanishing at parse.

The publish door has none of those three: no declaration, no responseSchema reference, no conformance gate. So version on the publish response is in the same undeclared state version on the save response was in before #5745 — and it is the same ADR-0008 OCC token, with the same "echo it back as If-Match" job.

Why it is observation-class rather than a defect

The route answers correctly and the body is well-formed; no caller is broken today. The cost is the one #5745 named: a public wire surface with no contract behind it, so a later field addition or removal is invisible instead of gated, and PublishMetaItemResponse cannot be named at the type level the way SaveMetaItemResponse can (packages/client/src/index.ts:18 imports and re-exports the latter).

Adjacent, and the reason this surfaced

#4717's metadata half puts the runtime authoring gate's advisory findings on the saveMetaItem response. The gate runs on both doors — protocol.ts:9133 for a direct active save, and protocol.ts:9869 for the draft-to-active promotion inside publishMetaItem (deliberately, per #4463 D1, so ?mode=draft then /publish is not a bypass). So whoever designs the advisory field will face two response envelopes, one declared and conformance-gated, one not declared at all. That asymmetry is worth knowing before the shape is chosen, not after.

Filed unassigned per Prime Directive #10. No pm:queue — observation class.

Activity

  1. self-assigned this
    on Aug 10, 2026
  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    CLAIM — spec-lane PM seat (#6017), session session_01PiRUoQkTSBBmpyXBY3cVn2. Branch: claude/issue-7294-declare-publish-response. Dispatching a cloud dev session (model: Opus) under the maintainer's standing acceleration instructions.

    Direction: mirror the #5745 discipline one door over, exactly as the card lays out — the publish door gets the same three artifacts the save door has: (1) PublishMetaItemResponseSchema in packages/spec/src/api/protocol.zod.ts declaring the full measured body (success/version/seq/message + conditional seedApplied/materializeApplied/projectionApplied, from protocol.ts:9771-9798); (2) the endpoint declaration in plugin-rest-api.zod.ts (POST {metaPath}/:type/:name/publish, handler: 'publishMetaItem', responseSchema reference), matching the 6 existing metadata-category entries' shape; (3) a producer-side conformance gate mirroring save-meta-response-conformance.test.ts including its header discipline. Premise-first: re-measure that the route is still served and still undeclared. New public exports expected ⇒ dual-snapshot rule in full.


    Generated by Claude Code

  3. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    OS-DEV-REPORT

    {
      "issue": 7294,
      "outcome": "success",
      "branch": "claude/issue-7294-declare-publish-response",
      "pr_number": 7356,
      "premise_check": {
        "base": "origin/main @ 88154be",
        "route_still_served": true,
        "route_evidence": "rest-server.ts:5481 registers POST {metaPath}/:type/:name/publish; :5499 awaits publishMetaItem(...); :5504 res.json(result) verbatim",
        "response_body_still_as_measured": true,
        "body_evidence": "protocol.ts:9769-9798 — one response literal: success/version/seq/message, then seedApplied/materializeApplied/projectionApplied attached conditionally by runPublishSideEffects",
        "publishmetaitem_hits_under_packages_spec_src": 0,
        "publish_hits_in_plugin_rest_api_zod": 0,
        "single_producer_confirmed": true,
        "verdict": "premise holds in full — not already declared; proceeded"
      },
      "changes": [
        "packages/spec/src/api/protocol.zod.ts — PublishMetaItemResponseSchema (full measured body; success/version/seq required, message + seedApplied/materializeApplied/projectionApplied optional) + PublishMetaItemResponse alias, modelled field-for-field on SaveMetaItemResponseSchema",
        "packages/spec/src/api/plugin-rest-api.zod.ts — endpoint entry POST /:type/:name/publish, handler publishMetaItem, category metadata, responseSchema PublishMetaItemResponseSchema, permissions ['metadata.write']; publishMetaItem added to the group's methods",
        "packages/objectql/src/publish-meta-response-conformance.test.ts — NEW producer-side gate mirroring save-meta-response-conformance.test.ts incl. header discipline; 9 cases covering plain shape + each conditional receipt path",
        "packages/spec/src/api/protocol.test.ts — spec-side half the gate's header points at; 8 cases pinning what the schema says",
        "packages/client/src/index.ts — publishItem() typed Promise<PublishMetaItemResponse> (was any); type re-exported",
        "packages/metadata-protocol/src/protocol.ts — publishMetaItem's Promise<...> annotation gains projectionApplied (assigned in the body, missing from the type)",
        "packages/spec/src/type-alias-convention.pin.test.ts — ADR-0122 pin Iso836; count 824 -> 825 + both prose statements",
        "packages/spec/src/api/plugin-rest-api.test.ts — metadata endpoint count 5 -> 6 with the receipt",
        ".changeset/declare-publish-meta-item-response.md — spec: minor, metadata-protocol + client: patch"
      ],
      "judgement_calls": {
        "message_optional_not_required": "producer sets it unconditionally today, but its own signature says message?, the SaveMetaItemResponse twin is optional, and an optional-but-always-present field strips nothing — stripping is the #5745 failure mode. success/version/seq ARE required (sole producer's only success return always sets them), with the tripwire case in the gate.",
        "no_request_schema": "#3899 — body optional, only read key is `message` taken only when already a string; the route cannot 400 a malformed body, so a requestSchema would advertise a gate that does not run."
      },
      "client_reexport_decision": {
        "included": true,
        "why": "mirror is exact and cheap — publishItem() already existed and already returned unwrapResponse<any>, the concrete symptom the card named. One type import + one re-export line, both beside the SaveMetaItemResponse ones. No extra surface pulled in; client suite 279/279 green, tsc --noEmit clean."
      },
      "reverse_verification": {
        "direction_predicted_first": true,
        "prediction": "removing the seedApplied block from the schema turns the producer gate RED in exactly 2 cases (the two seed paths) via non-empty strippedKeys; the other 7 stay green; the save-door gate is untouched",
        "probe": "omission — deleted the seedApplied z.object block from PublishMetaItemResponseSchema",
        "result_matched_prediction": true,
        "observed_red": "publish gate 2 failed / 7 passed, both failures `expected [ 'seedApplied' ] to deeply equal []` at the strippedKeys lines; spec-side protocol.test.ts 2 failed (the two seedApplied cases); save-meta-response-conformance.test.ts 5/5 untouched",
        "observed_green_as_landed": "publish gate 9/9, protocol.test.ts 44/44, save gate 5/5",
        "stronger_than_predicted": "`pnpm --filter @objectstack/spec build` itself REFUSES the removal before any test runs — '1 authorable key(s) disappeared from the contract: api/PublishMetaItemResponse:seedApplied'. The declaration is covered by the authorable-surface gate too, not only the two suites.",
        "restored": "probe reverted from backup; schema file byte-identical to pre-probe state; spec rebuilt and all suites re-run green"
      },
      "gates": {
        "spec_build_before_dist_derived_regen": "yes — #7122 trap hit once in the honest direction: gen:api-surface REFUSED ('dist older than src') after a post-build source edit, was re-run after a rebuild",
        "check_generated": "all 11 artifacts up to date",
        "typecheck": "turbo typecheck spec/client/objectql/metadata-protocol/rest + deps — 35/35",
        "spec_suite": "362 files / 9473 tests passed",
        "objectql_suite": "169 files / 2950 tests passed",
        "rest_suite": "77 files / 1228 tests passed",
        "runtime_suite": "119 files / 1870 tests passed",
        "metadata_protocol_suite": "67 files / 862 tests passed",
        "client_suite": "21 files / 279 tests passed",
        "root_gates": "check:route-envelope, check:doc-authoring, check:quick-reference-counts, check:nul-bytes — all green",
        "heavy_runs_flocked": "/tmp/os-heavy-verify.lock"
      },
      "snapshots": {
        "dual_snapshot_rule": "satisfied in full, regenerated after a real build",
        "api_surface": "packages/spec/api-surface/api.json — PublishMetaItemResponse (type), PublishMetaItemResponseSchema (const)",
        "export_origins": "packages/spec/export-origins/api.json — both, resolved to src/api/protocol.zod.ts#...",
        "docs_reference_rows": "content/docs/references/api/protocol.mdx + index.mdx via the gen chain (1575 -> 1576 schemas)",
        "other_generated": "authorable-surface/api.json, json-schema.manifest/api.json, docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (api/ 393 -> 397)"
      },
      "deviations": [
        "Added the spec-side pin suite in protocol.test.ts beyond the three artifacts named. Not scope creep: the conformance gate's mirrored header asserts 'the spec-side suite pins what the schema says; this one pins that the schema still matches what the code emits' — that sentence is false without it, and #5745 landed both halves.",
        "Also fixed packages/metadata-protocol/src/protocol.ts: publishMetaItem's Promise<...> annotation omitted projectionApplied while the implementation assigned it. Left alone, the new schema would declare a field the producer's own TS type denies. Same declared-!=-returned class, one layer down.",
        "The in-memory driver harness is duplicated into the new test rather than shared. save-meta-response-conformance.test.ts and plugin.authoring-channel.test.ts each already carry their own copy, so self-contained is the established shape; extracting would edit two landed files for no gate.",
        "Worktree setup: `git branch -D` was blocked by the permission classifier, so the shared checkout was detached and the pre-existing branch checked out in the worktree instead (same commit). Net effect identical."
      ],
      "docs_adr_touched": false,
      "special_inspection_notes": [
        "MetadataProtocol (protocol.zod.ts:1840) still does NOT declare publishMetaItem — the REST layer reaches it via `(p as any).publishMetaItem`. Naming it on the interface needs a PublishMetaItemRequest type, i.e. a request schema, which the card scoped out. Left as-is; flagging for whoever wants the cast gone (Prime Directive #12).",
        "For #4717's metadata half: the asymmetry the card warned about is now GONE — both write doors have a declared, conformance-gated response envelope, so an advisory-findings field can be added to the two symmetrically.",
        "The `seed` conformance case had to be written env-wide (no organizationId): ADR-0005/#6190 refuse an org-scoped write for a type whose registry entry says allowOrgOverride:false, and `seed` is one. Documented at the fixture. The seed path exercises the real SUCCESS branch — {success:true, inserted:1, updated:0}, verified by probe, not the surfaced-failure fallback — so the assertion pins the counters rather than their types.",
        "No out-of-scope finding issues filed — nothing surfaced that was not either in scope or noted above.",
        "NO auto-merge enabled. PR #7356 is a DRAFT awaiting the PM's serial landing."
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions