Skip to content
Open
26 changes: 26 additions & 0 deletions .changeset/33277-publish-drafts-published-unregistered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-protocol': patch
'@objectstack/runtime': patch
'@objectstack/client': patch
'@object-ui/app-shell': patch
---

feat(metadata-protocol, spec): a package publish whose runtime registry refused an object answers `published_unregistered` and names the object, instead of `published`

Clause-②: yes (widening) — `PublishPackageDraftsResponseSchema.outcome` gains the value `published_unregistered`, and the response gains the optional member `unregistered[]`. Every other answer is byte-identical: a publish the registry takes whole still answers `published` with `success: true` and no `unregistered` key.
Metadata-protocol: semantic

The authorable-surface shard gains `api/PublishPackageDraftsResponse:unregistered`, and the same server event answers `outcome` / `success` differently; ADR-0087 entry `18.publish-drafts-outcome-published-unregistered`.

**What answers differently.** `POST /api/v1/packages/:id/publish-drafts` (and the protocol's `publishPackageDrafts`, and `client.packages.publishDrafts()`) used to answer `outcome: 'published'` and `success: true` when the runtime registry refused one of the objects it had just made live. The object carried the `object-registry-refused` warning on its `published[]` element, but a caller that read `success` or `outcome` saw a clean publish, and a seed load or data read right after it answered `OBJECT_NOT_FOUND`. The same publish now answers:

- `outcome: 'published_unregistered'` and `success: false` (`success` is still exactly `outcome === 'published'`);
- `unregistered: [{ type: 'object', name, rule: 'object-registry-refused' }]`, one entry per refused object, in publish order;
- everything else as before: the drafts landed and stay live, so every object is still in `published[]` (the refused one still carries its warning), `failed[]` is empty, and the commit, seeds, probes and receipts are as they were.

**What to do.** Handle the new value wherever you branch on the batch publish answer. Do not treat it as "nothing landed": the metadata is live. Do not seed or read an object `unregistered[]` names until it is registered: publish it again, which re-runs the registration, or restart, which registers every stored object.

**The console.** Studio's publish, the package sheet, Home's *Publish all* and the AI chat card read `published_unregistered` as they read the `published` answer the server gave for this before: the refused object's warning reaches the author through the client, and none of them reports the publish as failed.

**Unchanged on purpose.** The REST door's app visibility flip (ADR-0045 §3) still runs after this outcome, as it ran after the `published` answer this used to be. The single-item doors (`publishMetaItem`, a publish-mode save) still answer success with the warning on their `advisories`.
13 changes: 11 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2625,12 +2625,13 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | True only when every pending draft promoted (`failed` empty) AND at least one item published. A pre-flight refusal or an ADR-0067 D2 rollback answers false on a 200 — but so does a publish with nothing to promote, so false alone is NOT a refusal: read `outcome`, not this boolean or the HTTP status. Always equal to `outcome === 'published'` (pinned). It does NOT cover the best-effort receipts below, each of which reports its own `success`. |
| **outcome** | `Enum<'published' \| 'refused' \| 'nothing_to_publish'>` | ✅ | First-class discriminant for WHICH exit answered — the fact `success` compresses into one boolean. `published`: at least one draft promoted and none refused. `refused`: the batch was refused — a pre-flight violation or the ADR-0067 D2 all-or-nothing rollback; the per-item story is in `failed[]`, which is non-empty exactly on this outcome (the invariant consumers previously had to reverse-engineer, now stated by the producer). `nothing_to_publish`: the package had no pending drafts — nothing landed AND nothing was refused; `success` stays false (a no-op is not a successful publish), which before this field made that answer indistinguishable from a refusal. Producer invariants, pinned in the conformance suites: `success === (outcome === 'published')`; `refused` if and only if `failed.length > 0`; `nothing_to_publish` if and only if `published.length === 0 && failed.length === 0`. Values are lowercase snake, matching the `sys_metadata_audit` outcome vocabulary. |
| **success** | `boolean` | ✅ | True only when every pending draft promoted (`failed` empty) AND at least one item published. A pre-flight refusal or an ADR-0067 D2 rollback answers false on a 200 — but so does a publish with nothing to promote, so false alone is NOT a refusal: read `outcome`, not this boolean or the HTTP status. Always equal to `outcome === 'published'` (pinned), so it is also false on `published_unregistered`, where the drafts landed but an object is not served. It does NOT cover the best-effort receipts below, each of which reports its own `success`. |
| **outcome** | `Enum<'published' \| 'published_unregistered' \| 'refused' \| 'nothing_to_publish'>` | ✅ | First-class discriminant for WHICH exit answered — the fact `success` compresses into one boolean. `published`: at least one draft promoted, none refused, and the runtime registry took every promoted object. `published_unregistered`: the drafts landed exactly as on `published` — the metadata is live and stays live — but the runtime registry refused at least one promoted object, so that object is not served: until something registers it, data reads, writes and seed loads against it answer OBJECT_NOT_FOUND. `unregistered[]` names each such object; do not seed or read it before it is registered (publish it again, or restart). `refused`: the batch was refused — a pre-flight violation or the ADR-0067 D2 all-or-nothing rollback; the per-item story is in `failed[]`, which is non-empty exactly on this outcome (the invariant consumers previously had to reverse-engineer, now stated by the producer). `nothing_to_publish`: the package had no pending drafts — nothing landed AND nothing was refused; `success` stays false (a no-op is not a successful publish), which before this field made that answer indistinguishable from a refusal. Producer invariants, pinned in the conformance suites: `success === (outcome === 'published')`; `refused` if and only if `failed.length > 0`; `nothing_to_publish` if and only if `published.length === 0 && failed.length === 0`; `published_unregistered` if and only if `unregistered` is present. Values are lowercase snake, matching the `sys_metadata_audit` outcome vocabulary. |
| **publishedCount** | `integer` | ✅ | Number of drafts promoted to active — `published.length`. 0 on every refusal path (the batch is all-or-nothing, ADR-0067 D2). |
| **failedCount** | `integer` | ✅ | Number of items that did not publish — `failed.length`. On a rollback this counts the WHOLE batch: the causal item plus every sibling marked BATCH_ABORTED. |
| **published** | `{ type: string; name: string; version: string; advisories?: object[] }[]` | ✅ | Every draft promoted to active, in publish order. Empty on every refusal path. |
| **failed** | `{ type: string; name: string; error: string; code?: string; … }[]` | ✅ | Items that did not publish. Because the batch is all-or-nothing (ADR-0067 D2), a non-empty list means NOTHING landed: `published: []`, `publishedCount: 0`. |
| **unregistered** | `{ type: string; name: string; rule: Enum<'object-registry-refused'> }[]` | optional | Present ONLY on `outcome: 'published_unregistered'`, and then non-empty: each promoted object the runtime registry refused, in publish order. Every entry also appears in `published[]` — its metadata IS live, nothing was rolled back (ADR-0067 D2 runs the registration after the metadata commit) — but the object is not served until something registers it, so a caller about to seed or read it should publish it again or restart first. Absent on every other outcome; an empty array is never emitted. |
| **seedApplied** | `{ success: boolean; inserted?: integer; updated?: integer; error?: string; … }` | optional | Aggregate outcome of materializing EVERY published `seed` body in one multi-pass loader run (cross-seed references need the whole set). Present ONLY when the batch published at least one seed. Two producers, one key: the batch itself self-applies (`applySeedBodies`), and the REST door back-fills the same key for custom protocols that do not — never both (an externalId-less seed would double-insert). Best-effort: a seed problem is surfaced here, never thrown. |
| **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; failures: object[] }` | optional | ADR-0086 P2 — aggregate result of publish-time materializers across the batch (e.g. `flow`, whose draft credentials become the live ones), including side-effect failures surfaced by the per-item effects loop. Present ONLY when at least one published item had a registered materializer or a side-effect failure. Best-effort, same contract as `seedApplied`. |
| **probes** | `any` | optional | ADR-0038 L3 post-publish runtime probe report — one real read per published artifact (seeded objects have rows, views are readable, widget dataset selections execute). DELIBERATELY OPAQUE in this contract: the key is declared and carried through verbatim, but its inner shape is intentionally not modeled until a consumer needs a field of it. Present only when something was publishable; probes never fail the publish. |
Expand Down Expand Up @@ -2659,6 +2660,14 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
| **code** | `string` | optional | Machine code for the refusal class (SCREAMING_SNAKE, ADR-0112 vocabulary) — e.g. a pre-flight violation code, or BATCH_ABORTED on the non-causal items of a rolled-back batch. |
| **issues** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | The structured findings behind the refusal, when the refusing error carried them — today the author-time gate's INVALID_METADATA refusal on the causal item. The producer has emitted this key; declaring it is what lets a typed consumer read it back, and what lets `error` stay a headline without losing the per-path detail. Same element shape as `published[].advisories` and the single-item 422's `issues[]` (— one dialect, declared once). Present ONLY on the causal item and only when the refusal produced structured findings; BATCH_ABORTED siblings never carry it. Absent means "this refusal carried no structured findings", never "no problems". |

### Nested Shape: `PublishPackageDraftsResponse.unregistered[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **type** | `string` | ✅ | Metadata type of the published item the registry refused (`object`). |
| **name** | `string` | ✅ | Name of the object that is published but not served. |
| **rule** | `Enum<'object-registry-refused'>` | ✅ | Why it is not served, as a stable rule id: the same id as the warning on this object's `published[]` element, whose `message` and `hint` say what happened and how to fix it. |

### Nested Shape: `PublishPackageDraftsResponse.seedApplied`

| Property | Type | Required | Description |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `api/` | 507 |
| `api/` | 508 |
9 changes: 7 additions & 2 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3304,8 +3304,13 @@ export class ObjectStackClient {
* Nameable at the type level only since #9406, which declared
* `PublishPackageDraftsResponseSchema` — the batch sibling of #7294's
* single-item declaration; this method resolved to `any` before that.
* The batch is all-or-nothing (ADR-0067 D2): `success: false` on a 200
* means NOTHING landed — read `failed[]`. Each `published[]` element's
* The batch is all-or-nothing (ADR-0067 D2), but `success: false` on a
* 200 does not by itself mean nothing landed — read `outcome`: `refused`
* means NOTHING landed (read `failed[]`), `nothing_to_publish` means there
* was nothing to promote, and `published_unregistered` (#33277) means the
* drafts landed but the runtime registry refused the objects
* `unregistered[]` names, which are not served until registered: publish
* them again or restart before seeding or reading them. Each `published[]` element's
* `version` is the ADR-0008 OCC token (pass to `saveItem` as
* `options.ifMatch`, which sends it as `If-Match`), and the
* conditional receipts (`seedApplied` / `materializeApplied` /
Expand Down
50 changes: 40 additions & 10 deletions packages/metadata-protocol/src/protocol-history-and-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ import type { SysMetadataRepository } from './sys-metadata-repository.js';
import type { HistoryVersionOwner } from './sys-metadata-repository.js';
import { historySlotOf, refusePackageSelectorOutsideSlottedTypes } from './protocol-history-slots.js';
import { captureStoredExpressionChange, fillStoredExpressionChange, type StoredExpressionFillCapture } from './protocol-stored-expression-fill.js';
import { reportObjectRegistryRefusal } from './protocol-registry-hydration.js';
import { OBJECT_REGISTRY_REFUSED, reportObjectRegistryRefusal } from './protocol-registry-hydration.js';

/**
* [#32396] A promotion a package publish wrote: what {@link undoLandedPromotions}
Expand Down Expand Up @@ -1540,8 +1540,12 @@ export class ProtocolHistoryAndPublishFacet {
* `success === (outcome === 'published')` — `success` keeps its exact
* pre-#10462 value and is now derivable. Values are lowercase snake,
* matching the `sys_metadata_audit` outcome vocabulary.
* [#33277] `published_unregistered`: the drafts landed as on
* `published`, but the runtime registry refused a promoted object, so
* it is not served; `unregistered` is present if and only if this is
* the outcome, and `success` is false on it.
*/
outcome: 'published' | 'refused' | 'nothing_to_publish';
outcome: 'published' | 'published_unregistered' | 'refused' | 'nothing_to_publish';
publishedCount: number;
failedCount: number;
published: Array<{
Expand All @@ -1566,6 +1570,14 @@ export class ProtocolHistoryAndPublishFacet {
advisories?: RuntimeAuthoringIssue[];
}>;
failed: Array<{ type: string; name: string; error: string; code?: string }>;
/**
* [#33277] Each promoted object the runtime registry refused, in
* publish order, under the rule its `published[]` element's warning
* carries. Present only on `outcome: 'published_unregistered'`, and
* then non-empty; the object stays in `published[]` (its metadata is
* live) but is not served until something registers it.
*/
unregistered?: Array<{ type: string; name: string; rule: 'object-registry-refused' }>;
/** Aggregate result of materializing every published `seed` (absent when no seeds). */
seedApplied?: { success: boolean; inserted: number; updated: number; error?: string; errors?: unknown[]; landed?: SeedAppliedLandedParsed[] };
/**
Expand Down Expand Up @@ -2505,6 +2517,10 @@ export class ProtocolHistoryAndPublishFacet {
if (capture && !storedExpressions.has(capture.object)) storedExpressions.set(capture.object, { capture, at });
});
const storageRefusedObjects = new Set<string>();
// [#33277] The promoted objects the registry refused at their own
// write-through below, in publish order: what turns the answer into
// `published_unregistered`.
const unregistered: Array<{ type: string; name: string; rule: typeof OBJECT_REGISTRY_REFUSED }> = [];
await registerAdmittedBatchRows(this, promotedObjects, admittedOrder);
for (const p of promoted) {
// [#9343] Omitted-when-empty, never `advisories: []` — the #4717
Expand Down Expand Up @@ -2540,7 +2556,13 @@ export class ProtocolHistoryAndPublishFacet {
// `object-registry-refused`: the batch still answers
// `published` (outcome and `success` unchanged), and this is
// the element a caller reads to learn the object is not served.
if (eff.registryRefused) element.advisories = [...(element.advisories ?? []), eff.registryRefused];
// [#33277] …and the batch no longer answers `published` over
// it: the object is named in `unregistered[]` under the same
// rule, and the outcome below is `published_unregistered`.
if (eff.registryRefused) {
element.advisories = [...(element.advisories ?? []), eff.registryRefused];
unregistered.push({ type: p.d.type, name: p.d.name, rule: OBJECT_REGISTRY_REFUSED });
}
if (eff.storageRefused) {
element.advisories = [...(element.advisories ?? []), eff.storageRefused];
storageRefusedObjects.add(p.d.name);
Expand Down Expand Up @@ -2654,20 +2676,28 @@ export class ProtocolHistoryAndPublishFacet {
);
}

const outcome = failed.length > 0
? 'refused'
: published.length === 0
? 'nothing_to_publish'
: unregistered.length > 0 ? 'published_unregistered' : 'published';
return {
success: failed.length === 0 && published.length > 0,
// [#33277] Still exactly `outcome === 'published'`: a batch whose
// drafts landed while the registry refused an object among them is
// not a successful publish for a caller about to use that object.
success: outcome === 'published',
// [#10462] Derived, never stored: 'refused' the moment anything is
// in `failed[]` (defensive — every failure on this route unwinds
// through the Phase-1 catch above today), else 'published' iff
// something landed, else the no-op. Exactly the three-way fact
// `success` compresses into one boolean.
outcome: failed.length > 0
? 'refused'
: published.length > 0 ? 'published' : 'nothing_to_publish',
// through the Phase-1 catch above today), else the no-op when
// nothing landed, else [#33277] 'published_unregistered' when the
// registry refused a promoted object, else 'published'. Exactly the
// fact `success` compresses into one boolean.
outcome,
publishedCount: published.length,
failedCount: failed.length,
published,
failed,
...(unregistered.length > 0 ? { unregistered } : {}),
...(seedApplied ? { seedApplied } : {}),
...(materialize.any
? { materializeApplied: {
Expand Down
Loading
Loading