diff --git a/.changeset/compound-meta-door-mode-draft.md b/.changeset/compound-meta-door-mode-draft.md new file mode 100644 index 0000000000..511d300334 --- /dev/null +++ b/.changeset/compound-meta-door-mode-draft.md @@ -0,0 +1,52 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): `?mode=draft` now stages on the compound-name metadata write door + +`PUT /api/v1/meta/:type/:section/:name` — the compound-name door, the one you +reach with a name like `views/all_leads` — built its `saveMetaItem` request +field by field and `mode` was not one of the fields. Its single-segment twin +`PUT /api/v1/meta/:type/:name` has read that parameter all along. The parameter +was never refused here, only dropped, so the request was answered `200` and +published **live**. Both doors now read it. + +**Two behaviour changes, and both can be observed by an unchanged caller.** + +**1. `?mode=draft` on this door changes OUTCOME, not acceptance.** The request +was accepted before and is accepted now; what moved is what it does. + +| Request | Before | After | +| --- | --- | --- | +| `PUT /meta/object/crm/task?mode=draft` | `200`, `"state":"active"` — the live row overwritten, nothing staged | `200`, `"state":"draft"` — a staged row written, the live row untouched | + +If you send `?mode=draft` to a compound name today and rely on the write going +live — for instance because you never call `POST /meta/:type/:name/publish` — +those writes stop taking effect immediately and start waiting for a promotion. +Drop the parameter to keep publishing straight away. `mode=publish`, an +unrecognised `mode=`, an empty `mode=` and no `mode` at all are all unchanged: +they publish, exactly as before. The spelling test is the twin's, `draft` +case-insensitive. + +⚠️ **The draft you can now stage has no per-item REST promotion door in this +arity.** `POST /meta/:type/:name/publish` is mounted for single-segment names +only, while its read twin `GET /meta/:type/:section/:name/published` is mounted +for both — so a compound-named draft is writable and readable over REST and not +promotable there. Until that route exists, promote through +`POST /packages/:id/publish-drafts` (whole-package) or the runtime dispatcher's +own `meta.publish` verb. Tracked in #11932; this release does not change it. + +**2. A repeated `?mode` is now REFUSED where it was accepted.** This narrows +what the door takes. `?mode=draft&mode=draft` arrives as an array; the +`typeof === 'string'` test is false for it, so before this change it fell back +to publishing live under a `200`. It is now answered `400` +`{ "error": { "code": "VALIDATION_ERROR" } }` and nothing is written — the +#6877 guard this door already applied to `force` and `package`, extended to the +parameter it just gained, and the same answer the single-segment twin has given +for a repeated `mode` since #6877. A single occurrence encoded as an array +(`?mode=draft` once) is still accepted; the guard unwraps rather than +blanket-refusing. + +Nothing else on the door moved: `?force`, `?package`, the `meta-envelope` +write face, the `manage_metadata` gate and the `501` envelope are untouched, +and the single-segment twin is untouched. diff --git a/packages/rest/src/meta-compound-save-mode-parity.test.ts b/packages/rest/src/meta-compound-save-mode-parity.test.ts new file mode 100644 index 0000000000..83954981cb --- /dev/null +++ b/packages/rest/src/meta-compound-save-mode-parity.test.ts @@ -0,0 +1,547 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11712] `?mode=draft` on the compound-name `PUT /api/v1/meta/:type/:section/:name` + * — the FIFTH divergence closed on this door pair, and the first one whose + * harmful direction is a silent WRITE rather than a silent refusal. + * + * ## The defect, as measured + * + * ADR-0005's per-item lifecycle stages a write when the caller sends + * `?mode=draft`; `POST /meta/:type/:name/publish` promotes it later. The + * single-segment `PUT /meta/:type/:name` reads that parameter and threads it. + * The compound-name twin built its `saveMetaItem` request field by field and + * `mode` was not one of the fields, so it fell to `saveMetaItem`'s `'publish'` + * default. Driven through the real registered handlers against one store: + * + * ``` + * COMPOUND PUT /meta/object/crm/task?mode=draft → 200 {"state":"active"} + * row_compound name=crm/task state=active label=NEW_LABEL ← LIVE, overwritten + * SINGLE PUT /meta/object/crm_task?mode=draft → 200 + * row_single name=crm_task state=active label=ACTIVE_LABEL ← LIVE, untouched + * r_2 name=crm_task state=draft label=NEW_LABEL ← staged + * ``` + * + * One name, spelled two ways, and the parameter is not refused at either door — + * it is honoured at one and dropped at the other, with a `200` both times. The + * caller asked for a staging buffer and got a publish. + * + * ## Why threading, and not refusing the parameter here + * + * #7019's ruling, inherited with its reason for the fifth time: this route is + * "word for word the same operation" as its twin — one generic `saveMetaItem` + * reached by a name spelled in two segments — and every divergence found on the + * pair has been closed on that finding (#6603/#7019's `manage_metadata` gate, + * #8805's write-side organization, #7035's 501 envelope, #11095's `?force`). + * #11095's carve-out is for the runtime DISPATCHER, which has no query string + * at all; it does not describe this door, which has one and already reads two + * parameters off it. + * + * The fork triage left open — "the draft door may have a real reason to stay + * single-segment only" — was measured and is CLOSED in the negative: + * + * • `saveMetaItem` keys the draft on `type`/`name`/organization/package and + * passes `state` to `repo.put`. Nothing in that path reads the name's + * SHAPE, so `crm/task` is a draft key exactly like `crm_task` is. + * • The ADR-0033 read half is already mounted in BOTH arities — + * `GET /:type/:section/:name/published` (#7526), whose own comment cites the + * SDK's `getPublished('lead', 'views/all_leads')` and calls a compound name + * "how every other read on this surface addresses a sub-resource". + * + * A compound draft is a shape this surface already serves on the read side. + * Only the write door was missing. + * + * ## Why the REAL protocol and not a double + * + * The subject is a route's query-string handling, but the claim worth pinning + * is what the write DID. A double that only recorded the request would pass + * against a door that names `mode` and a store that ignores it; and a + * status-only assertion passes against the UNFIXED door, which answers `200` + * while publishing live — this card's defect exactly. So the gate is the real + * `ObjectStackProtocolImplementation` over a `sys_metadata`-backed engine and + * every case reads the STORE: which row is live, which row is staged, and which + * body each of them carries. + * + * ⚠️ That import resolves through `exports` to `@objectstack/metadata-protocol`'s + * **`dist/`** (registered in `check-test-source-alias.mjs`'s + * `KNOWN_UNALIASED_TEST_IMPORTS` for this package), so this suite is a verdict + * about the BUILT protocol. Rebuild it before reading a result here after + * touching `protocol.ts`. + * + * ## Body shapes + * + * Two, and they are the file's rather than a typo — the same split + * `meta-compound-save-force-parity.test.ts` documents. The `200` save answer is + * the protocol's own `{ success, version, seq, state, message }`. The `400` + * from `refuseRepeatedQueryParams` is hand-built by the route and NESTED: + * `{ error: { code, message } }`. + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — NodeNext resolution requires the extension (#7248). +import { RestServer } from './rest-server.js'; +import { assertEngineUpdateDispatch, assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; + +const META = '/api/v1/meta'; +const COMPOUND_PATH = `${META}/:type/:section/:name`; +const SINGLE_PATH = `${META}/:type/:name`; + +/** The compound URL `section` + `name` spell, and the single-segment twin's. */ +const COMPOUND_NAME = 'crm/task'; +const SINGLE_NAME = 'crm_task'; + +/** What the seeded LIVE row carries, and what a published save would replace. */ +const LIVE_LABEL = 'Live label'; +/** What every case submits. A staged save must NOT make this the live label. */ +const SUBMITTED_LABEL = 'Edited label'; + +/** + * A spec-valid `object` body. `sharingModel` is not decoration: ADR-0090 D1's + * author-time gate refuses an unset OWD (`security-owd-unset`), and without it + * the save would fail a phase before the one under test — a red that reads + * exactly like "the parameter did not work". + * + * The field set is IDENTICAL to the seeded row's on purpose: this card is about + * draft-vs-live, so nothing here may trip `saveMetaItem`'s Phase 3a-destructive + * gate (that is #11095's card, pinned next door). Only the label moves. + */ +const objectBody = (label: string) => ({ + name: SINGLE_NAME, + label, + sharingModel: 'private', + fields: Object.fromEntries(['a', 'b'].map((f) => [f, { name: f, type: 'text', label: f }])), +}); + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(), + }; + return res; +} + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum: string; + version: number; +} + +/** + * Boot both `PUT` doors over the REAL protocol against ONE store, seeded so + * both names exist LIVE with the same body. The single-segment twin is a + * control in every case rather than a separate suite, because "the two doors + * agree" is the claim. + */ +function boot() { + const rows = new Map(); + const seed = (id: string, name: string) => rows.set(id, { + id, type: 'object', name, + organization_id: null, package_id: null, state: 'active', + metadata: JSON.stringify(objectBody(LIVE_LABEL)), + checksum: 'sha256_11712_fixture', version: 1, + }); + seed('row_compound', COMPOUND_NAME); + seed('row_single', SINGLE_NAME); + + /** + * Scalar equality ONLY, and every combinator is REFUSED rather than + * approximated — `pnpm check:where-matcher` + * (`scripts/check-where-matcher-conformance.mjs`, #8494): "a discovered + * matcher must answer every combinator probe CORRECTLY, or REFUSE it by + * throwing". A `$and` silently falling through to `r['$and']` compares + * `undefined` against an array, excludes the row and returns an empty + * result set with nothing erroring — a suite can go green while asserting + * about a DIFFERENT query than the one the protocol sent. `$`-prefixed keys + * are never field names (`protocol.ts`'s `FILTER_LOGICAL_KEYS`), so the + * guard is a prelude rather than an arm inside the loop: a preceding scalar + * miss must not short-circuit `.every` past an operator we cannot answer. + */ + const match = (r: any, where: Record): boolean => { + for (const k of Object.keys(where ?? {})) { + if (k.startsWith('$')) { + throw new Error(`fake engine: unsupported logical operator ${k}`); + } + } + return Object.entries(where ?? {}).every(([k, v]) => + v === null || v === undefined + ? r[k] === null || r[k] === undefined + : r[k] === v, + ); + }; + + const engine: any = { + async find(table: string, o?: { where?: Record }) { + if (table !== 'sys_metadata') return []; + return [...rows.values()].filter((r) => match(r, o?.where ?? {})); + }, + async findOne(table: string, o: { where: Record }) { + if (table !== 'sys_metadata') return null; + for (const r of rows.values()) if (match(r, o?.where ?? {})) return r; + return null; + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata') { + const r = { ...(data as any), id: String(data.id ?? `r_${rows.size}`) } as StoredRow; + rows.set(r.id, r); + } + return { id: String(data.id ?? 'r_new') }; + }, + // ⛔ Routed through the producer-side predicates, never hand-mirrored: + // a double looser than `ObjectQL` turns a green suite into no suite + // (`check:engine-double-contract`, #4550 / #5480). This file NEEDS the + // write verbs, because every assertion below is on the STORE — a draft + // save INSERTS a second row and a published save UPDATES the first, and + // telling those apart is the whole card. + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + const id = (opts as any)?.where?.id; + const existing = id ? rows.get(String(id)) : undefined; + if (existing) rows.set(String(id), { ...existing, ...(data as any) }); + return { id: id ?? null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + registry: { + registerItem: () => {}, registerObject: () => {}, listItems: () => [], + getItem: () => undefined, getArtifactItem: () => undefined, + removeRuntimeShadow: () => false, removeOverlayEntry: () => {}, uninstallPackage: () => {}, + }, + }; + + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); + + /** + * Every request the doors hand the protocol, recorded at the seam. The + * store answers "what did the write do"; this answers "with what" — and the + * distinction is the defect: the pre-fix door reached `saveMetaItem` on + * every one of these calls, it simply never named `mode` in the object. + */ + const seen: any[] = []; + const realSave = protocol.saveMetaItem.bind(protocol); + protocol.saveMetaItem = async (request: any) => { seen.push(request); return realSave(request); }; + + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + // `manage_metadata` held — the #7019 capability gate is a different card and + // must not be what answers here. + (rest as any).resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + + const route = (method: string, path: string) => (rest as any).getRoutes().find( + (r: any) => r.method === method && r.path === path, + ); + + const call = async (path: string, params: Record, query: Record) => { + const res = mockRes(); + await route('PUT', path)!.handler({ params, query, headers: {}, body: objectBody(SUBMITTED_LABEL) }, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + + /** Rows for one metadata name, by lifecycle state — read from the STORE. */ + const labelOf = (name: string, state: string): string | undefined => { + for (const r of rows.values()) { + if (r.name === name && r.state === state) return JSON.parse(r.metadata)?.label; + } + return undefined; + }; + + return { + seen, + /** + * The pair this whole file turns on, for one name: what the LIVE row + * carries and what (if anything) is STAGED beside it. Returned as a + * tuple so a parity case can compare the two doors without either side + * naming the outcome — see §5. + */ + outcome: (name: string) => [labelOf(name, 'active'), labelOf(name, 'draft')] as const, + compoundOutcome: () => [labelOf(COMPOUND_NAME, 'active'), labelOf(COMPOUND_NAME, 'draft')] as const, + singleOutcome: () => [labelOf(SINGLE_NAME, 'active'), labelOf(SINGLE_NAME, 'draft')] as const, + /** The door under test. */ + compoundPut: (query: Record = {}) => + call(COMPOUND_PATH, { type: 'object', section: 'crm', name: 'task' }, query), + /** Its single-segment twin — the control, already correct before this card. */ + singlePut: (query: Record = {}) => + call(SINGLE_PATH, { type: 'object', name: SINGLE_NAME }, query), + }; +} + +/** What the store looks like when a save was STAGED: live untouched, draft beside it. */ +const STAGED = [LIVE_LABEL, SUBMITTED_LABEL]; +/** What it looks like when a save went LIVE: live replaced, nothing staged. */ +const PUBLISHED = [SUBMITTED_LABEL, undefined]; + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. ⭐ The compound door, `?mode=draft` — the case that fails without the fix +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11712] compound-name PUT — `?mode=draft` stages instead of publishing', () => { + it('⭐ leaves the LIVE row alone and stages the edit beside it', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ mode: 'draft' }); + + expect(answer.status).toBe(200); + // ⛔ The status is NOT the pin. The unfixed door answers 200 here too — + // it accepts the parameter and ignores it. These two lines are the + // claim: the caller's edit is STAGED, and the live body is untouched. + expect(stack.compoundOutcome()).toEqual(STAGED); + // Pre-fix this read `['Edited label', undefined]`: the live row + // overwritten, nothing staged. A publish, answered 200, for a request + // that asked for a draft. + }); + + it('⭐ says so in the answer too — `state` is the staged one, not the live one', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ mode: 'draft' }); + + // The protocol's save answer carries the lifecycle state it wrote. + // Pre-fix this said `active` while reporting success — the silence the + // card is about, one field along from the store itself. + expect(answer.body?.state).toBe('draft'); + }); + + it('threads `mode: \'draft\'` into the protocol request, and only when asked', async () => { + const stack = boot(); + + await stack.compoundPut(); + await stack.compoundPut({ mode: 'draft' }); + + // The seam itself. The pre-fix door reached `saveMetaItem` on BOTH of + // these calls — it simply never named `mode` in either request, which + // is why a request-shape assertion localises the defect that a + // status-only assertion cannot see at all. + expect(stack.seen).toHaveLength(2); + expect(stack.seen[0].mode).toBeUndefined(); + expect(stack.seen[1].mode).toBe('draft'); + // The rest of the request is untouched by this card — same face, same + // compound name assembled from the two segments. + expect(stack.seen[1].name).toBe(COMPOUND_NAME); + expect(stack.seen[1].writeFace).toBe('meta-envelope'); + }); + + it('accepts the `DRAFT` spelling case-insensitively, byte-identically to the twin', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ mode: 'DRAFT' }); + + expect(answer.status).toBe(200); + expect(stack.compoundOutcome()).toEqual(STAGED); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. The fence — publishing is still the default, and still what everything +// that is not `draft` means. GREEN BOTH SIDES: a regression guard, not a +// red-before case. +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11712] the compound door still publishes when nothing asked for a draft', () => { + it.each([ + { label: 'no `mode` at all', query: {} }, + { label: 'an explicit `mode=publish`', query: { mode: 'publish' } }, + { label: 'an unrecognised `mode=staged`', query: { mode: 'staged' } }, + { label: 'an empty `mode=`', query: { mode: '' } }, + ])('$label goes live — legacy semantics, unchanged', async ({ query }) => { + const stack = boot(); + + const answer = await stack.compoundPut(query); + + expect(answer.status).toBe(200); + expect(stack.compoundOutcome()).toEqual(PUBLISHED); + expect(stack.seen[0].mode).toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. [#6877] The repeated-parameter guard — the second limb of this card +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11712 / #6877] a REPEATED `?mode` is refused, never read as publish-anyway', () => { + /** + * #6877's mechanism, unchanged, aimed at the parameter this card threads: a + * repeated `?mode=draft&mode=draft` arrives as an ARRAY, the + * `typeof req.query?.mode === 'string'` test is FALSE for it, and the save + * falls silently back to publishing live — the exact outcome this card + * exists to stop, re-entered through the door the fix opens. The twin has + * listed `mode` since #6877; this door listed two names because it read two + * parameters. Threading the third without naming it here would have shipped + * the guard gap on the same line as the repair. + * + * ⚠️ This narrows the accepted set: a repeated `mode` is answered 200 today + * and 400 after this card. That is the Clause-② limb the changeset states. + */ + it('⛔ `?mode=draft&mode=draft` is a 400 — NOT a silent publish', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ mode: ['draft', 'draft'] }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + // Stated as the assertion that would have caught the fall-back: the + // save must not have happened at all, let alone gone live. + expect(stack.seen).toHaveLength(0); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + }); + + it('⛔ `?mode=draft&mode=publish` is refused too — multiplicity, not intent', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ mode: ['draft', 'publish'] }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + }); + + it('one occurrence encoded as an array still stages — the guard unwraps, it does not blanket-refuse', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ mode: ['draft'] }); + + expect(answer.status).toBe(200); + expect(stack.compoundOutcome()).toEqual(STAGED); + }); + + it('the twin refuses a repeated `mode` the same way — it always did', async () => { + const stack = boot(); + + const answer = await stack.singlePut({ mode: ['draft', 'draft'] }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. The guard's existing entries must not have moved. GREEN BOTH SIDES — +// this describe block passes before and after the fix, and is reported as a +// regression guard rather than as evidence of the repair. +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11712 / #11095] adding `mode` to the list did not disturb `force` or `package`', () => { + it('⛔ a repeated `?force` is still a 400, and still writes nothing (#6877 inversion)', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ force: ['false', 'false'] }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(stack.seen).toHaveLength(0); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + }); + + it('⛔ a repeated `?package` is still a 400 (#6877, where the guard started)', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ package: ['pkg_a', 'pkg_b'] }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(stack.seen).toHaveLength(0); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + }); + + it('a single `?package` still binds the row, and composes with `?mode=draft`', async () => { + const stack = boot(); + + await stack.compoundPut({ package: 'pkg_a', mode: 'draft' }); + + // Both parameters reached the protocol from the same query string: this + // card added a reader beside the two that were already here, it did not + // replace them. + expect(stack.seen).toHaveLength(1); + expect(stack.seen[0]).toMatchObject({ packageId: 'pkg_a', mode: 'draft', name: COMPOUND_NAME }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. ⭐ [#7019] The twins agree — the ruling this card inherits, executable +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11712 / #7019] the two `PUT` doors answer `?mode` the same way', () => { + /** + * ⛔ Deliberately literal-free on both sides: these cases assert that the + * two doors AGREE, never what they agree on. §1 names the outcome; this + * section names only the equality, so a future move on EITHER door reddens + * here independently of whichever literal §1 happens to pin. (#11731 §4 is + * the precedent — "every classified refusal gets the same status at both + * doors", with the status read off the other door rather than written down.) + * + * The single-segment door is untouched by this card and is the control: its + * behaviour is asserted rather than assumed, which is the only shape in + * which "the twins agree" is a pin instead of a comment. + */ + it.each([ + { label: 'no `mode`', query: {} }, + { label: '`mode=draft`', query: { mode: 'draft' } }, + { label: '`mode=DRAFT`', query: { mode: 'DRAFT' } }, + { label: '`mode=publish`', query: { mode: 'publish' } }, + { label: '`mode=staged` (unrecognised)', query: { mode: 'staged' } }, + { label: 'a repeated `mode`', query: { mode: ['draft', 'draft'] } }, + ])('⭐ $label: same status, same lifecycle, same store outcome at both doors', async ({ query }) => { + const compoundStack = boot(); + const singleStack = boot(); + + const compound = await compoundStack.compoundPut(query); + const single = await singleStack.singlePut(query); + + // 1. The answer. + expect(compound.status).toBe(single.status); + expect(compound.body?.state).toBe(single.body?.state); + expect(compound.body?.error?.code).toBe(single.body?.error?.code); + // 2. What the write actually DID, as `[live label, staged label]`. + // Pre-fix, `mode=draft` read `['Edited label', undefined]` on the + // compound side and `['Live label', 'Edited label']` on the twin. + expect(compoundStack.compoundOutcome()).toEqual(singleStack.singleOutcome()); + }); + + it('and the twin is UNTOUCHED — its request shape is what it always was', async () => { + const stack = boot(); + + await stack.singlePut({ mode: 'draft' }); + + // The fence. This card threads a parameter on the compound door; it must + // not have edited the door that was already right. + expect(stack.seen).toHaveLength(1); + expect(stack.seen[0]).toMatchObject({ + type: 'object', name: SINGLE_NAME, mode: 'draft', writeFace: 'meta-envelope', + }); + }); + + it('both doors reach ONE store, so a draft staged at either is the same draft', async () => { + const stack = boot(); + + // Same fixture, both doors, both staging: the names differ, so the two + // drafts are two rows and neither door's staging leaks onto the other's + // live body. This is the claim "one generic `saveMetaItem`, reached by a + // name spelled in two segments" reduced to something executable. + await stack.compoundPut({ mode: 'draft' }); + await stack.singlePut({ mode: 'draft' }); + + expect(stack.outcome(COMPOUND_NAME)).toEqual(STAGED); + expect(stack.outcome(SINGLE_NAME)).toEqual(STAGED); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 51141bd5dc..15ea1db67e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6735,7 +6735,17 @@ export class RestServer { // Threading `force` here without also naming it here would // have re-opened that inversion on a fresh door, on a // destructive verb, reported as 200. - if (refuseRepeatedQueryParams(req, res, ['force', 'package'])) return; + // + // [#11712] `mode` joins for the same reason and in the same + // stroke as the read below. The single-segment twin has + // listed all three since #6877; this door listed two, + // because it read two. The mechanism is #6877's unchanged: + // a repeated `?mode=draft&mode=draft` arrives as an ARRAY, + // the `typeof req.query?.mode === 'string'` test below is + // FALSE for it, and the save falls silently back to + // publishing live — the very outcome this card is about, + // re-entered through the door the fix opens. + if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return; // [#11095] Phase 3a-destructive: `?force=true` opts past the // destructive-change safety check — BYTE-IDENTICAL to the // single-segment `PUT /meta/:type/:name` above, truthy @@ -6816,6 +6826,42 @@ export class RestServer { ...(actor ? { actor } : {}), ...(force ? { force: true } : {}), ...(packageId ? { packageId } : {}), + // [#11712] ADR-0005 per-item lifecycle: `?mode=draft` + // stages the write instead of publishing it live. + // BYTE-IDENTICAL to the single-segment + // `PUT /meta/:type/:name` above, spelling test and all, + // because it is byte-identically the same decision — + // #7019's ruling applied a fifth time, with its reason: + // this route is "word for word the same operation" as + // its twin, one generic `saveMetaItem` reached by a name + // spelled in two segments. #6603/#7019 (capability + // gate), #8805 (write-side org), #7035 (the 501 + // envelope) and #11095 (`?force`) each closed a + // divergence on this pair on exactly that finding. + // + // The harm was measured, not reasoned. Until this + // landed the request was built field by field with no + // `mode` among the fields, so `saveMetaItem` fell to its + // `'publish'` default: `PUT /meta/object/crm/task + // ?mode=draft` answered `200` with `state: 'active'` and + // OVERWROTE the live row, while the byte-identical + // intent one route over inserted a `state: 'draft'` row + // and left the live one alone. Nothing in the answer + // said the parameter had been ignored — a caller asking + // for a staging buffer got a publish. + // + // ⛔ NOT repaired by refusing the parameter here: the + // draft store keys on `type`/`name`/org/package and is + // indifferent to how the name is spelled, and the + // ADR-0033 read half is ALREADY mounted in both arities + // (`GET /:type/:section/:name/published`, #7526, whose + // own comment cites `getPublished('lead', + // 'views/all_leads')`). A compound draft is a shape this + // surface already serves; only the write door was + // missing. + ...((typeof req.query?.mode === 'string' + && req.query.mode.toLowerCase() === 'draft') + ? { mode: 'draft' } : {}), } as any); res.json(result); } catch (error: any) { diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 52fc5d0f0c..c621212d54 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1691,6 +1691,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/meta-compound-save-mode-parity.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/rest/src/meta-compound-save-mode-parity.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/rest/src/meta-published-overlay.test.ts", "verb": "delete",