Skip to content

Commit e001364

Browse files
committed
Merge origin/main 4b45afa into claude/issue-19278-shard-hash-carries-closure
Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude <noreply@anthropic.com>
2 parents 099e5ff + 4b45afa commit e001364

59 files changed

Lines changed: 1819 additions & 318 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Notes in twenty-one more liveness ledgers, and one `datasource` evidence string, cite the commit that decided them, or say the decision in words, instead of a tracker number that no longer resolves
6+
7+
Clause-②: no
8+
9+
Notes in the `book`, `doc`, `job`, `validation`, `translation`, `hook`, `seed`, `flow`,
10+
`capability`, `qa`, `dashboard`, `action`, `agent`, `skill`, `tool`, `rest_api`,
11+
`route_generation`, `crud_endpoints`, `metadata_endpoints`, `batch_endpoints` and
12+
`analytics_cube` ledgers cited tracker numbers that no longer resolve on GitHub, so a reader
13+
could not tell why a row carries its verdict. Each such note now either names the commit that
14+
made the decision or, where the number alone carried the meaning, says what was decided. The
15+
`datasource` ledger's `ssl.rejectUnauthorized` evidence string cited one such number in its
16+
prose; it now names the commit that made the fix, and its code anchors are unchanged. The
17+
`liveness/` ledgers ship in this package's tarball, which is why this is a release note at all.
18+
Note text and that one evidence parenthesis only: no row's status, proof or date changes, and
19+
no schema, export or runtime behaviour changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
Provenance comments in `service-automation` were re-anchored
6+
7+
Comment and docblock lines under `src/` that cited tracker numbers which no
8+
longer resolve on GitHub now cite the record in this repository that decided
9+
the matter (an ADR where one exists, otherwise the commit in this repository's
10+
history), and say in their own words what was decided. Comments only: no type,
11+
schema, export, log or refusal text, or runtime behaviour changes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
fix(runtime): the `/automation` write doors refuse a packaged flow, as the metadata door does (#20679)
7+
8+
Clause-②: yes (widening)
9+
10+
A flow that a code package ships has a locked base (ADR-0126 §2): changing or removing it in place is refused. `PUT /api/v1/meta/flow/:name` already refused it. The two `/automation` definition doors did not: an administrator holding `manage_metadata` could rewrite a packaged flow in the live engine with `PUT /api/v1/automation/:name` or with `POST /api/v1/automation` under its name (a create onto an existing name overwrites it), or remove it with `DELETE /api/v1/automation/:name`.
11+
12+
All three now answer a packaged flow with the same code and status the metadata door gives (`403` `NOT_OVERRIDABLE`), and with the same sentence wherever the metadata protocol's own package door answers. The refusal comes before the engine is called, so nothing is registered or removed. On `DELETE`, it also comes before the engine's own `DELETE_RESTRICTED` / `409` for a packaged subflow that packaged callers still reach.
13+
14+
What is not refused:
15+
16+
- A flow that no code package ships, including a flow created with `POST /api/v1/automation` or authored through the metadata door. It is updated and removed as before.
17+
- `POST /api/v1/automation/:name/clone`, which copies a packaged flow under a new name. This is the supported way to customize one (ADR-0126 §7.1).
18+
- `POST /api/v1/automation/:name/toggle`, the switch that turns a packaged flow on or off (ADR-0126 §7.2).
19+
- A deployment that sets `OS_METADATA_WRITABLE=flow`. It opens both doors, as the refusal message says.
20+
21+
**The widening.** `@objectstack/metadata-protocol` gains one public method, `ObjectStackProtocolImplementation.packagedBaseRefusal({ type, name, operation })`. It returns the refusal the metadata door would give for writing (`'save'`) or removing (`'delete'`) an existing item because a code package ships it, or `null` when that door would not refuse on this ground. `saveMetaItem` and `deleteMetaItem` call the same code, so the two doors cannot disagree. Their own refusals are unchanged.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20679, ADR-0126 §2] `packagedBaseRefusal` — the `/meta` door's
5+
* locked-base verdict, as a value, for a second door onto the same artifact.
6+
*
7+
* It is not a new rule. It hands out the SAME verdict `saveMetaItem` and
8+
* `deleteMetaItem` reach — the package doors both now call, lifted out of them
9+
* unchanged, `refusePackagedBaseOverride` / `refusePackagedBaseRemoval` — so this file
10+
* pins two things and only two:
11+
*
12+
* 1. it answers exactly what those two methods throw for the same item (the
13+
* ONE-emitter claim: same code, status and sentence);
14+
* 2. its matrix is the metadata door's matrix, including the answers that are
15+
* deliberately `null` — a name no package ships, a Regime O overlay type,
16+
* the #6960 delete carve-out, and the operator hatch.
17+
*
18+
* The registry double serves only `getArtifactItem`, which is all the verdict
19+
* reads; what it returns is what the real `SchemaRegistry` returns for an
20+
* artifact a code package registered (`_packageId` stamped, package
21+
* provenance). `@objectstack/objectql` cannot be imported here: it depends on
22+
* this package.
23+
*/
24+
import { afterEach, describe, expect, it } from 'vitest';
25+
import { ObjectStackProtocolImplementation } from './protocol.js';
26+
import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
27+
28+
const PACKAGE_ID = 'com.example.pkg';
29+
30+
const shipped = (name: string, extra: Record<string, unknown> = {}) =>
31+
({ name, label: name, _packageId: PACKAGE_ID, _provenance: 'package', ...extra });
32+
33+
/** Packaged artifacts of three regimes: behavioral (`flow`), overlay (`view`), rolled-back overlay (`page`). */
34+
const ARTIFACTS = new Map<string, Map<string, unknown>>([
35+
['flow', new Map([['pkg_flow', shipped('pkg_flow', { type: 'autolaunched', nodes: [], edges: [] })]])],
36+
['view', new Map([['pkg_view', shipped('pkg_view')]])],
37+
['page', new Map([['pkg_page', shipped('pkg_page')]])],
38+
]);
39+
40+
function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
41+
const registry = { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) };
42+
return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId);
43+
}
44+
45+
const shape = (e: any) => (e ? { code: e.code, status: e.status } : null);
46+
47+
afterEach(() => {
48+
delete process.env.OS_METADATA_WRITABLE;
49+
ObjectStackProtocolImplementation.resetEnvWritableCache();
50+
resetEnvWritableMetadataTypes();
51+
});
52+
53+
describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed to a second door', () => {
54+
for (const environmentId of [undefined, 'env_1']) {
55+
it(`refuses a packaged flow's save AND removal on ${environmentId ? 'an environment' : 'a host-config'} kernel`, () => {
56+
const p = protocolOn(environmentId);
57+
expect(shape(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })))
58+
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
59+
expect(shape(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })))
60+
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
61+
});
62+
}
63+
64+
it('ONE emitter: the value equals what saveMetaItem / deleteMetaItem throw for the same item', async () => {
65+
const p = protocolOn('env_1');
66+
const thrownBy = (run: Promise<unknown>) => run.then(() => undefined, (e: any) => e);
67+
68+
const save = await thrownBy(p.saveMetaItem({ type: 'flow', name: 'pkg_flow', item: { name: 'pkg_flow', label: 'x' } }));
69+
const saveVerdict: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' });
70+
expect({ ...shape(save), message: save?.message }).toEqual({ ...shape(saveVerdict), message: saveVerdict?.message });
71+
72+
const del = await thrownBy(p.deleteMetaItem({ type: 'flow', name: 'pkg_flow' }));
73+
const delVerdict: any = p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' });
74+
expect({ ...shape(del), message: del?.message }).toEqual({ ...shape(delVerdict), message: delVerdict?.message });
75+
});
76+
77+
it('folds the type at the producer — a plural spelling cannot address around the lock', () => {
78+
const p = protocolOn(undefined);
79+
expect(shape(p.packagedBaseRefusal({ type: 'flows', name: 'pkg_flow', operation: 'save' })))
80+
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
81+
});
82+
83+
it('a name no code package ships is not locked — creation is not this verdict\'s question', () => {
84+
const p = protocolOn(undefined);
85+
expect(p.packagedBaseRefusal({ type: 'flow', name: 'customer_flow', operation: 'save' })).toBeNull();
86+
expect(p.packagedBaseRefusal({ type: 'flow', name: 'customer_flow', operation: 'delete' })).toBeNull();
87+
});
88+
89+
it('a Regime O overlay type (allowOrgOverride) is never refused on this ground', () => {
90+
const p = protocolOn(undefined);
91+
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull();
92+
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'delete' })).toBeNull();
93+
});
94+
95+
it('mirrors the #6960 carve-out: a rolled-back overlay type refuses the write but not the removal', () => {
96+
const p = protocolOn(undefined);
97+
expect(shape(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' })))
98+
.toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
99+
expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull();
100+
});
101+
102+
it('a lookup that FAILS is re-raised, never handed out as a verdict', () => {
103+
// The lifted helpers throw, as the inline code did; only the refusal is
104+
// turned into a value. A registry that cannot answer must not become a
105+
// well-formed "refused" (or "allowed") — it stays the fault it is.
106+
const registry = { getArtifactItem: () => { throw new Error('registry unreadable'); } };
107+
const p = new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), undefined);
108+
expect(() => p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }))
109+
.toThrow('registry unreadable');
110+
expect(() => p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }))
111+
.toThrow('registry unreadable');
112+
});
113+
114+
it('reads the operator hatch through the same predicate the metadata door reads', () => {
115+
process.env.OS_METADATA_WRITABLE = 'flow';
116+
ObjectStackProtocolImplementation.resetEnvWritableCache();
117+
const p = protocolOn(undefined);
118+
expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })).toBeNull();
119+
expect(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' })).toBeNull();
120+
});
121+
});

‎packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise<unknown>) {
210210
}
211211

212212
describe('#9157 — the population, re-derived from the code rather than from the card', () => {
213-
it('every `/meta` request-boundary verb with a required `type` calls the fold — all thirteen', () => {
213+
it('every `/meta` request-boundary verb with a required `type` calls the fold — all fourteen', () => {
214214
// ⭐ The card hand-listed "nine fold, three do not". Hand-listed sets of
215215
// this shape have shipped short before, so the set is DERIVED here and
216216
// the derivation is the pin: a tenth verb arriving unfolded turns this
@@ -280,6 +280,9 @@ describe('#9157 — the population, re-derived from the code rather than from th
280280
// in-process caller hands it a type spelling too.
281281
'getMetaItemsForExecution',
282282
'historyMetaItem',
283+
// [#20679] The locked-base verdict a second write door asks — it
284+
// takes the type a caller names, so it folds at the producer too.
285+
'packagedBaseRefusal',
283286
'publishMetaItem',
284287
'rollbackMetaItem',
285288
'saveMetaItem',

0 commit comments

Comments
 (0)