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

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:

link pinned by
the publish notifies metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts (5 cases)
the host announces metadata:reloaded objectql/src/plugin-publish-announce.test.ts (4 cases)
the announce binds the flow, no restart service-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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 20, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 20, 2026 15:37
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 20, 2026
Merged via the queue into main with commit d728325 Aug 20, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10219-per-item-publish-rebind branch August 20, 2026 16:03
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/xl tests tooling

Projects

None yet

1 participant