Skip to content

fix(metadata-protocol): per-item publish re-binds runtime consumers and finds env-wide drafts - #10291

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-10219-per-item-publish-rebind
Aug 20, 2026
Merged

fix(metadata-protocol): per-item publish re-binds runtime consumers and finds env-wide drafts#10291
os-zhuang merged 3 commits into
mainfrom
claude/issue-10219-per-item-publish-rebind

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#10219

The per-item publish door — POST /api/v1/meta/:type/:name/publish, the one AI authoring
and the item-level Studio doors take — was missing two things the package-scoped door has.
Both are fixed at the producer, so every transport that reaches the protocol inherits them.

A — a publish now announces metadata:reloaded on both doors

metadata:reloaded is the platform's declared "re-read what you cached" signal, and it had
exactly two announcers: the metadata plugin's dev-artifact watcher, and the runtime
dispatcher after POST /packages/:id/publish-drafts (#2576). Publishing item by item fired
neither. Measured on a cloud rig: two flows published as state='active' produced no bind
log and never executed; only a forced kernel rebuild picked them up, via the kernel:ready
cold-boot bind (#2560). Same silence for other packages' authored hooks/actions, declared
connectors and authored translations.

publishMetaItem now notifies through a new onMetaItemPublished seam, and
ObjectQLPlugin.subscribeMetadataRebind — armed for both protocol assembly modes —
turns that into ctx.trigger('metadata:reloaded', { changed }). The producer notifies and
the host announces because the protocol holds no kernel hook bus; that is the same division
HttpDispatcher.announceKernelEvent already makes for the batch door. changed carries the
batch door's {type}/{name} spelling, so a subscriber cannot tell the two doors apart.

Three deliberate choices, each pinned by a case:

  • Not the existing onMetadataMutation. That event is emitted from
    runPublishSideEffects, which the batch door runs once per promoted draft — announcing on
    it would fan a full kernel re-sync (schema DDL, connector re-materialization, flow re-bind)
    out once per item of a "publish whole app". A CONTROL case pins that the batch door emits
    none of the new event, so its single per-publish announce is unchanged.
  • Awaited, so the publish's own 2xx means the re-bind was attempted rather than queued —
    a caller that publishes a flow and immediately writes a record must not race the bind.
  • No new wire key. The batch door carries rebindError because its response is a batch
    receipt; adding a zero-reader diagnostic key to PublishMetaItemResponseSchema buys no
    capability (the objectui: the Unpublished banner and per-app publish read _unpublished; the App Switcher keeps filtering on hidden (#4829 A1 objectui half) #6955 ruling, one payload over), so a failed announce is reported through
    the log — warn, matching the sibling announcers — and never fails the publish.

B — a per-item publish now resolves the draft's own org scope

Four AI-authored view drafts sitting at state='draft' in sys_metadata, listed by the
console's pending-changes banner and promoted by its one-click "publish 4 changes" button,
were each refused per item with 404 [no_draft] No pending draft exists for view/<name> — nothing to publish.

view is one of the types the registry declares allowOrgOverride: true, so
organizationIdForMetaWrite threads the session's active organization into the publish. The
drafts were authored env-wide (organization_id = NULL) — what package/AI authoring writes —
and a strict organization_id = <org> lookup can never match them. object and flow are
not org-overridable, which is exactly why per-item publish worked for them and failed for
views: one symptom, split by a registry flag.

This is the single-item twin of #3115. The batch door fixed it by discovering each
draft's scope (listDrafts surfaces a non-null-org caller's own rows and the env-wide ones
through its $or, and the promote targets d.organizationId); the per-item door deduced
one instead. It now discovers, with the ADR-0005 precedence — an org holding its own draft
publishes that one, and only an org with no draft of its own falls through to the env-wide
row it was already authoring into. Resolved before every gate below it, so the ADR-0010 lock
check, the #6190 org-scoped-write refusal and the promote all judge the one scope the row is
actually in. When neither scope holds a draft, the caller's own scope is returned unchanged,
so a genuinely absent draft still raises the same NO_DRAFT refusal. No catch: a driver
failure fails the publish rather than resolving to a scope nobody verified.

Regression coverage — the chain, end to end

The issue's requirement 3 ("publish a record-change flow per item, write a record, assert it
executes") is pinned as three links rather than one integration test, because the third link
already had one:

linkpinned by
the publish notifiesmetadata-protocol/src/protocol.publish-item-rebind-announce.test.ts (5 cases)
the host announces metadata:reloadedobjectql/src/plugin-publish-announce.test.ts (4 cases)
the announce binds the flow, no restartservice-automation/src/flow-publish-rebind.test.ts — pre-existing, already asserts that changed: ['flow/<name>'] binds a flow the protocol newly serves

Defect B has its own file, protocol.publish-item-draft-org-scope.test.ts (4 cases), with
the org-precedence and still-NO_DRAFT controls beside the fix.

Reverse verification. With the B hunks removed from the committed tree (helper deleted
and its call site deleted; confirmed on disk by asserting resolveDraftOrgScopeForPublish
no longer appears in the file), the org-scope file goes 2 failed | 7 passed, failing with
the issue's exact sentence — [no_draft] No pending draft exists for view/customer_list — nothing to publish. — while the PRECEDENCE and NO_DRAFT controls stay green (they do not
depend on the fallback) and the announce file stays entirely green, so the two fixes are
independent. Restored, all 9 pass again. The tests import the mutated module from source in
its own package, so no dist rebuild is involved on either leg.

Verification

Run at 713a78452 (the final commit):

  • pnpm --filter "@objectstack/objectql^..." build → exit 0 (dependency closure, built first)
  • pnpm --filter @objectstack/metadata-protocol testTest Files 126 passed | 2 skipped, Tests 1717 passed | 10 skipped
  • pnpm --filter @objectstack/objectql testTest Files 222 passed (222), Tests 3925 passed (3925)
  • pnpm --filter @objectstack/objectql typecheck → exit 0 (metadata-protocol declares no
    typecheck script — it is a measured entry in the coverage ledger; its tsup DTS build is
    the type signal it has, and it succeeded)
  • gate union derived by node scripts/pm/dispatch-gates.mjs (no paths), re-run at
    713a78452: check:nul-bytes, check:cross-package-test-inputs,
    check:durability-log-level, check:filter-alias-parity, check:slot-lookup,
    check:engine-double-contract, check:where-matcher, check:query-options-erasure,
    check-adr-0087-registration, check-changeset-no-major, check-empty-changeset — all
    exit 0. check:engine-double-contract first reported the two new stub engines as unpinned
    coverage; regenerated with --write (4 rows added or grown, 0 lost) in its own commit.

Two gates in the derived list fail identically on an untouched main checkout
(68f65ff60) on this macOS host and are unrelated to this change:
check:changeset-gate-self-tests (its check-adr-0087-registration --self-test I2 cases:
the gate spawned inside the temp fixture exits 0 and prints nothing) and
check:objectui-changeset (ENOENT on a /var/folders/.../objectui-digest-selftest-*
fixture path). Both are self-test/fixture failures in a temp directory, neither reads a file
this PR touches, and CI runs them on Linux.

scripts/pm/os-verify-lock.sh could not be used on this host — filed separately, see below.

Out of scope

Generated by Claude Code

…ublish
`POST /api/v1/meta/:type/:name/publish` promoted a draft to `active` and told
nobody. The lifecycle event that makes boot-cached consumers re-read had two
announcers — the metadata plugin's dev-artifact watcher, and the runtime
dispatcher after `POST /packages/:id/publish-drafts` (#2576) — so publishing
item by item, which is what AI authoring and the item-level Studio doors do,
fired neither. Measured on a cloud rig: a record-change flow published as
`state='active'` produced no bind log and never executed until the kernel was
rebuilt and `kernel:ready` re-bound it (#2560).
`publishMetaItem` now notifies through a new `onMetaItemPublished` seam, and
`ObjectQLPlugin.subscribeMetadataRebind` — armed for both protocol assembly
modes — turns that into `ctx.trigger('metadata:reloaded', { changed })`. The
producer notifies and the host announces because the protocol holds no kernel
hook bus, which is the same division `HttpDispatcher.announceKernelEvent` makes
for the batch door. `changed` carries the batch door's `{type}/{name}` spelling,
so a subscriber cannot tell the two doors apart.
The notification is deliberately NOT the existing `onMetadataMutation`: that one
is emitted from `runPublishSideEffects`, which the batch door runs once per
promoted draft, so announcing on it would fan a full kernel re-sync (schema DDL,
connector re-materialization, flow re-bind) out once per item of a
"publish whole app". A CONTROL case pins that the batch door emits none.
Awaited, so the publish's own 2xx means the re-bind was attempted rather than
queued; best-effort, so a throwing subscriber is logged at `warn` and never
turns a landed publish into an error. No wire key is added — the batch door's
`rebindError` exists because its response is a batch receipt, and adding a
zero-reader diagnostic key to `PublishMetaItemResponseSchema` buys no capability.
Part of #10219
…em publish
The per-item publish door could not SEE a draft the batch door published fine.
Measured on a cloud rig: four AI-authored `view` drafts sitting at
`state='draft'` in `sys_metadata` — listed by the console's pending-changes
banner, promoted by its one-click "publish 4 changes" button — were each refused
per item with `404 [no_draft] No pending draft exists for view/<name> — nothing
to publish.`
`view` is one of the types the registry declares `allowOrgOverride: true`, so
`organizationIdForMetaWrite` threads the session's active organization into the
publish. The drafts were authored env-wide (`organization_id = NULL`), which is
what package / AI authoring writes, and a strict `organization_id = <org>`
lookup can never match them. `object` and `flow` are not org-overridable, which
is exactly why per-item publish worked for them and failed for views — one
symptom, split by a registry flag.
This is the single-item twin of #3115. The batch door fixed it by DISCOVERING
each draft's scope (`listDrafts` surfaces a non-null-org caller's own rows and
the env-wide ones through its `$or`, and the promote targets `d.organizationId`);
the per-item door DEDUCED one instead. It now discovers, with the ADR-0005
precedence — an org holding its own draft publishes that one, and only an org
with no draft of its own falls through to the env-wide row it was already
authoring into.
Resolved before every gate below it, so the ADR-0010 lock check, the #6190
org-scoped-write refusal and the promote all judge the one scope the row is
actually in. When neither scope holds a draft the caller's own scope is returned
unchanged, so a genuinely absent draft still raises the same `NO_DRAFT` refusal
from the scope it asked about. No `catch`: a driver failure must fail the
publish rather than resolve to a scope nobody verified.
Fixes#10219
`check:engine-double-contract` retains a per-file pin for every test double that
routes its `delete()` / `update()` through `assertEngineDeleteDispatch` /
`assertEngineUpdateDispatch`. The two stub engines added with this card's
regression tests are pinned coverage the ledger did not know about, so it was
not protecting those files. Regenerated with `--write`: 4 rows added or grown,
0 lost.
Part of #10219
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql, touching 25 documentable anchor(s).

22 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 108fae98448b29a8a26fdd29cd71300e1cd5ee50.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 19 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 108fae98448b29a8a26fdd29cd71300e1cd5ee50packageMentionDocs.

Which tree this was computed on

This run read content/docs from 248eeb10f3b42e6d9d7802caeb7772d718a4ab78 — the merge of head 713a784526390764cffda72d4f4653d106316caa into base 108fae98448b29a8a26fdd29cd71300e1cd5ee50, 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 248eeb10f3b42e6d9d7802caeb7772d718a4ab78 && git checkout 248eeb10f3b42e6d9d7802caeb7772d718a4ab78
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 108fae98448b29a8a26fdd29cd71300e1cd5ee50 713a784526390764cffda72d4f4653d106316caa && git checkout -B drift-repro 108fae98448b29a8a26fdd29cd71300e1cd5ee50 && git merge --no-ff 713a784526390764cffda72d4f4653d106316caa
node scripts/docs-audit/affected-docs.mjs --json 108fae98448b29a8a26fdd29cd71300e1cd5ee50

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

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

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

1 participant

@os-zhuang