Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): per-item publish re-binds runtime consumers and finds env-wide drafts - #10291
Conversation
…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
📓 Docs Drift CheckThis PR changes 2 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10219
The per-item publish door —
POST /api/v1/meta/:type/:name/publish, the one AI authoringand 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:reloadedon both doorsmetadata:reloadedis the platform's declared "re-read what you cached" signal, and it hadexactly two announcers: the metadata plugin's dev-artifact watcher, and the runtime
dispatcher after
POST /packages/:id/publish-drafts(#2576). Publishing item by item firedneither. Measured on a cloud rig: two flows published as
state='active'produced no bindlog and never executed; only a forced kernel rebuild picked them up, via the
kernel:readycold-boot bind (#2560). Same silence for other packages' authored hooks/actions, declared
connectors and authored translations.
publishMetaItemnow notifies through a newonMetaItemPublishedseam, andObjectQLPlugin.subscribeMetadataRebind— armed for both protocol assembly modes —turns that into
ctx.trigger('metadata:reloaded', { changed }). The producer notifies andthe host announces because the protocol holds no kernel hook bus; that is the same division
HttpDispatcher.announceKernelEventalready makes for the batch door.changedcarries thebatch door's
{type}/{name}spelling, so a subscriber cannot tell the two doors apart.Three deliberate choices, each pinned by a case:
onMetadataMutation. That event is emitted fromrunPublishSideEffects, which the batch door runs once per promoted draft — announcing onit 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.
a caller that publishes a flow and immediately writes a record must not race the bind.
rebindErrorbecause its response is a batchreceipt; adding a zero-reader diagnostic key to
PublishMetaItemResponseSchemabuys nocapability (the objectui: the Unpublished banner and per-app publish read
_unpublished; the App Switcher keeps filtering onhidden(#4829 A1 objectui half) #6955 ruling, one payload over), so a failed announce is reported throughthe 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
viewdrafts sitting atstate='draft'insys_metadata, listed by theconsole'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.viewis one of the types the registry declaresallowOrgOverride: true, soorganizationIdForMetaWritethreads the session's active organization into the publish. Thedrafts were authored env-wide (
organization_id = NULL) — what package/AI authoring writes —and a strict
organization_id = <org>lookup can never match them.objectandflowarenot 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 (
listDraftssurfaces a non-null-org caller's own rows and the env-wide onesthrough its
$or, and the promote targetsd.organizationId); the per-item door deducedone 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_DRAFTrefusal. Nocatch: a driverfailure 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:
metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts(5 cases)metadata:reloadedobjectql/src/plugin-publish-announce.test.ts(4 cases)service-automation/src/flow-publish-rebind.test.ts— pre-existing, already asserts thatchanged: ['flow/<name>']binds a flow the protocol newly servesDefect B has its own file,
protocol.publish-item-draft-org-scope.test.ts(4 cases), withthe org-precedence and still-
NO_DRAFTcontrols 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
resolveDraftOrgScopeForPublishno longer appears in the file), the org-scope file goes
2 failed | 7 passed, failing withthe 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 notdepend 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
distrebuild 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 test→Test Files 126 passed | 2 skipped,Tests 1717 passed | 10 skippedpnpm --filter @objectstack/objectql test→Test Files 222 passed (222),Tests 3925 passed (3925)pnpm --filter @objectstack/objectql typecheck→ exit 0 (metadata-protocoldeclares notypecheckscript — it is a measured entry in the coverage ledger; itstsupDTS build isthe type signal it has, and it succeeded)
node scripts/pm/dispatch-gates.mjs(no paths), re-run at713a78452: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— allexit 0.
check:engine-double-contractfirst reported the two new stub engines as unpinnedcoverage; 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
maincheckout(
68f65ff60) on this macOS host and are unrelated to this change:check:changeset-gate-self-tests(itscheck-adr-0087-registration --self-testI2 cases:the gate spawned inside the temp fixture exits 0 and prints nothing) and
check:objectui-changeset(ENOENTon 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.shcould not be used on this host — filed separately, see below.Out of scope
scripts/pm/os-verify-lock.shspins forever on bash 3.2 (macOS):mapfile/EPOCHSECONDSare bash 4+/5+, so the lock is never acquired and the command never runs #10289 —scripts/pm/os-verify-lock.shuses bash-4-only builtins(
mapfile,EPOCHSECONDS) behind#!/usr/bin/env bash, so on macOS's bash 3.2 it neveracquires and never runs the command: it spins, emitting
mapfile: command not found/EPOCHSECONDS: unbound variableforever. Measured here with all three of this round's devsessions stuck in it simultaneously. The build/test commands above were therefore run
directly, with
--workspace-concurrency=2to keep the load bounded — a declared narrowing,not a silent one.
Generated by Claude Code