Skip to content

Commit e58ea8b

Browse files
claude[bot]claude
andauthored
feat(spec): declare grantedPermissions on EnvironmentArtifactSchema — install-time granted set per plugin manifest id (#14865) (#14992)
* feat(spec): declare `grantedPermissions` on `EnvironmentArtifactSchema` (#14865) One optional top-level envelope key, `grantedPermissions: z.record(z.string(), PluginPermissionsSchema).optional()` — the install-time GRANTED permission set per plugin, keyed by the plugin manifest `id`. Producer: the cloud control plane's consent-compile step. Consumer: the materialize-time loader via `PluginPermissionEnforcer.registerGrantedPermissions`. Absent (no consent record) and `{}` (consented to nothing) are never collapsed; the value shape is the strict `PluginPermissionsSchema`, so an unknown permission class is refused at the artifact door. Pins: declared key survives and round-trips; absent stays absent; `{}` stays `{}`; an unknown top-level sibling is still stripped; value schema identity; the manifest-id-vs-package_id residual risk pinned on the key's own description. Doc-only carrier sentence on `PluginPermissionsSchema`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i * docs(spec): regenerate the environment-artifact reference page for the grantedPermissions docblock (#14865) check:generated proved exactly one artifact stale — content/docs/references/** — and regenerated it (--fix); authorable-surface, api-surface, export-origins and declaration-map are unchanged by the new optional key. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i * test(spec): add a pure sibling-strip control beside the grantedPermissions pins (#14865) A control that also asserts the new key survives goes red at the pre-change schema for the survival reason, so its strip half is never evaluated there. This one involves no grantedPermissions at all: green before and after the key, red only if the envelope door goes passthrough — the reading the ablation needs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i * docs(north-star): list grantedPermissions among the environment artifact contents (#14865) Patch round from the domain:spec seat: the docs-drift bot named content/docs/concepts/north-star.mdx via EnvironmentArtifactSchema — its "Environment Artifact" section enumerates the envelope's contents, so the new optional key belongs in that list. One bullet, in the page's register; the checksum bullet and the rest of the page are untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4e4d2d commit e58ea8b

6 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `grantedPermissions` on `EnvironmentArtifactSchema` — the install-time granted permission set per plugin, keyed by manifest `id` (#14865)
6+
7+
The environment artifact envelope (`@objectstack/spec/system`, re-exported from
8+
`@objectstack/spec/cloud`) gains one optional top-level key:
9+
10+
```ts
11+
grantedPermissions?: Record<string, PluginPermissions> // keyed by the plugin manifest `id`
12+
```
13+
14+
This is the artifact-contract half of #11333 option A / the #13457 batch ruling:
15+
the consented four-class permission set `{ services, hooks, network, fs }` rides
16+
the plugin artifact contract. The **producer** is the cloud control plane's
17+
consent-compile step (it already persists the set on
18+
`sys_package_installation.granted_permissions`; it now has a declared place to
19+
emit it on the envelope). The **consumer** is the materialize-time loader, which
20+
hands each entry to `PluginPermissionEnforcer.registerGrantedPermissions`, so a
21+
third-party plugin runs under exactly the surface the installer consented to —
22+
independent of what its manifest requested.
23+
24+
Why the spec half lands first: `EnvironmentArtifactSchema` is a plain `z.object`,
25+
so a key the control plane writes before it is declared is silently stripped at
26+
the runtime's artifact door. Declaring it is what makes the value reach the
27+
loader at all.
28+
29+
Contract points, each pinned by a parse test next to the schema:
30+
31+
- **Absent ≠ `{}`.** Absent = no consent record (first-party / pure-metadata
32+
package). `{}` = consent-bearing and consented to nothing. There is no
33+
`.default({})`; both round-trip as written.
34+
- **Key = the plugin manifest `id`**, not the control-plane `package_id` — the
35+
identity the enforcer is queried with. Documented residual risk: a package
36+
whose manifest `id` differs from its `package_id` must still be keyed by the
37+
manifest `id`; the schema cannot tell the two spellings apart.
38+
- **Value shape = `PluginPermissionsSchema`** (`.strict()`), the same declaration
39+
the manifest's requested set uses — an unknown permission class is refused at
40+
the artifact door, not granted silently.
41+
- An unknown top-level sibling key is still stripped (the door did not go
42+
passthrough).
43+
44+
Additive and optional: every artifact that parsed before parses identically.
45+
`ENVIRONMENT_ARTIFACT_SCHEMA_VERSION` stays `0.1` (it is bumped on breaking
46+
envelope changes only). No runtime behaviour changes in this package — the
47+
consumer wiring is #13457, behind cloud #14034.

content/docs/concepts/north-star.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ The artifact contains:
7373
serialization
7474
- `metadata` — the compiled `ObjectStackDefinition` itself
7575
- optional `builtAt` / `builtWith` provenance
76+
- optional `grantedPermissions` — the install-time GRANTED permission set per
77+
plugin, keyed by the plugin manifest `id`; consent state the control plane
78+
re-emits on each assembly (absent = no consent record, `{}` = consented to
79+
nothing), sitting beside `metadata` and outside the `checksum` digest
7680

7781
The artifact is enough to describe what the runtime should load. It is not
7882
enough to deploy by itself; the host still supplies deployment config.

content/docs/references/system/environment-artifact.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ identity (`commitId`, `checksum`).
3131
`checksum`.
3232
- **Deployment Config (NOT in this schema):** business DB coordinates,
3333
credentials, environment identity, secrets. Injected at runtime.
34+
- **Consent state (this schema, `grantedPermissions`, #14865):** the
35+
install-time GRANTED permission set per plugin, keyed by the plugin
36+
manifest `id` — written by the control plane at consent-compile time,
37+
read by the loader at materialize time. Control-plane state re-emitted
38+
on every artifact assembly, not compiled metadata: it sits beside
39+
`metadata`, outside the `checksum` digest. Absent ≠ `{}` — see the
40+
key's docblock.
3441

3542
See `content/docs/concepts/north-star.mdx` §6.3 for the
3643
runtime-inputs boundary.

packages/spec/src/kernel/manifest.zod.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ import { NavigationContributionSchema } from '../ui/app.zod';
2424
* the persisted `granted_permissions` set enforced at load by the
2525
* PluginPermissionEnforcer.
2626
*
27+
* The consented set reaches the runtime on the environment artifact
28+
* envelope — `EnvironmentArtifactSchema.grantedPermissions`
29+
* (`system/environment-artifact.zod.ts`, #14865): a map keyed by this
30+
* manifest's `id` whose values are this very schema. The loader reads it
31+
* from the environment-local carrier at materialize time, never from
32+
* `sys_package_installation` directly (ADR-0003 / cloud ADR-0007). Absent
33+
* there = no consent record; `{}` = consented to nothing.
34+
*
2735
* @example
2836
* ```jsonc
2937
* { "services": ["object", "http"], "hooks": ["record.beforeInsert"],

packages/spec/src/system/environment-artifact.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
EnvironmentArtifactSchema,
77
Sha256DigestSchema,
88
} from './environment-artifact.zod';
9+
import { PluginPermissionsSchema } from '../kernel/manifest.zod';
910

1011
import {
1112
EXPORT_ENTRY_POINTS,
@@ -246,3 +247,148 @@ describe('EnvironmentArtifactSchema (wire shape)', () => {
246247
});
247248
});
248249
});
250+
251+
// ─── grantedPermissions (#14865) ────────────────────────────────────────────
252+
//
253+
// The artifact-contract half of #11333 option A / the #13457 batch ruling: the
254+
// consented four-class permission set `{ services, hooks, network, fs }` rides
255+
// the envelope — written by the cloud control plane at consent-compile time,
256+
// read by the loader at materialize time. Before this key was declared,
257+
// `EnvironmentArtifactSchema` (a plain `z.object`) STRIPPED it at the artifact
258+
// door with no error — so the first pin is "the declared key survives", and its
259+
// positive control is "an unknown sibling is still stripped": the door must not
260+
// have gone passthrough to admit this key. Every pin here is a schema-reachable
261+
// parse, not a type-level assertion.
262+
263+
describe('grantedPermissions — install-time granted set per plugin manifest `id` (#14865)', () => {
264+
const granted = {
265+
'@acme/plugin-crm': {
266+
services: ['object', 'http'],
267+
hooks: ['record.beforeInsert'],
268+
network: ['api.acme.com'],
269+
fs: [],
270+
},
271+
'@acme/plugin-reports': {},
272+
};
273+
274+
it('the declared key survives parse and round-trips the exact map', () => {
275+
const parsed = EnvironmentArtifactSchema.parse({ ...wireMinimal, grantedPermissions: granted });
276+
expect(parsed).toHaveProperty('grantedPermissions');
277+
expect(parsed.grantedPermissions).toEqual(granted);
278+
expect(Object.keys(parsed.grantedPermissions ?? {})).toEqual(['@acme/plugin-crm', '@acme/plugin-reports']);
279+
});
280+
281+
it('pure control: with no grantedPermissions present at all, an unknown top-level sibling is stripped (green before and after this key; red only if the door goes passthrough)', () => {
282+
const parsed = EnvironmentArtifactSchema.parse({ ...wireMinimal, notAnEnvelopeKey: { anything: 1 } });
283+
expect(parsed).not.toHaveProperty('notAnEnvelopeKey');
284+
expect(Object.keys(parsed).sort()).toEqual(['checksum', 'commitId', 'environmentId', 'metadata', 'schemaVersion']);
285+
});
286+
287+
it('positive control: an unknown top-level sibling is STILL stripped — the door admits the declared key, not everything', () => {
288+
const parsed = EnvironmentArtifactSchema.parse({
289+
...wireMinimal,
290+
grantedPermissions: granted,
291+
grantedPermissionz: granted, // near-miss spelling
292+
notAnEnvelopeKey: { anything: 1 },
293+
});
294+
expect(parsed.grantedPermissions).toEqual(granted);
295+
expect(parsed).not.toHaveProperty('grantedPermissionz');
296+
expect(parsed).not.toHaveProperty('notAnEnvelopeKey');
297+
expect(Object.keys(parsed).sort()).toEqual(
298+
['checksum', 'commitId', 'environmentId', 'grantedPermissions', 'metadata', 'schemaVersion'],
299+
);
300+
});
301+
302+
describe('absent ≠ `{}` — never collapsed (there is no `.default({})`, and there must not be)', () => {
303+
it('absent stays absent: no consent record', () => {
304+
const parsed = EnvironmentArtifactSchema.parse(wireMinimal);
305+
expect(Object.keys(parsed)).not.toContain('grantedPermissions');
306+
expect(parsed.grantedPermissions).toBeUndefined();
307+
});
308+
309+
it('`{}` stays `{}`: consent-bearing, consented to nothing', () => {
310+
const parsed = EnvironmentArtifactSchema.parse({ ...wireMinimal, grantedPermissions: {} });
311+
expect(Object.keys(parsed)).toContain('grantedPermissions');
312+
expect(parsed.grantedPermissions).toEqual({});
313+
});
314+
315+
it('a per-plugin `{}` entry stays `{}`: that plugin consented to nothing', () => {
316+
const parsed = EnvironmentArtifactSchema.parse({
317+
...wireMinimal,
318+
grantedPermissions: { '@acme/plugin-reports': {} },
319+
});
320+
expect(parsed.grantedPermissions).toEqual({ '@acme/plugin-reports': {} });
321+
});
322+
323+
it('the two readings stay distinguishable on the PARSED value, not only on the input', () => {
324+
const absent = EnvironmentArtifactSchema.parse(wireMinimal);
325+
const empty = EnvironmentArtifactSchema.parse({ ...wireMinimal, grantedPermissions: {} });
326+
expect('grantedPermissions' in absent).toBe(false);
327+
expect('grantedPermissions' in empty).toBe(true);
328+
});
329+
});
330+
331+
describe('value shape = the strict PluginPermissionsSchema (kernel/manifest.zod.ts)', () => {
332+
it('is the SAME declaration as the manifest requested set — identity, not a lookalike', () => {
333+
const record = EnvironmentArtifactSchema.shape.grantedPermissions.unwrap();
334+
expect(record.valueType).toBe(PluginPermissionsSchema);
335+
});
336+
337+
it('refuses an unknown permission CLASS with `unrecognized_keys` at the plugin path, rather than granting it silently', () => {
338+
const result = EnvironmentArtifactSchema.safeParse({
339+
...wireMinimal,
340+
grantedPermissions: { '@acme/plugin-crm': { services: ['object'], shell: ['*'] } },
341+
});
342+
expect(result.success).toBe(false);
343+
if (result.success) return;
344+
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
345+
expect(issue?.path).toEqual(['grantedPermissions', '@acme/plugin-crm']);
346+
expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['shell']);
347+
});
348+
349+
it('refuses a non-object per-plugin value and a non-record map', () => {
350+
for (const bad of [
351+
{ '@acme/plugin-crm': ['object'] },
352+
{ '@acme/plugin-crm': 'object' },
353+
{ '@acme/plugin-crm': null },
354+
['object'],
355+
'object',
356+
]) {
357+
expect(
358+
EnvironmentArtifactSchema.safeParse({ ...wireMinimal, grantedPermissions: bad }).success,
359+
`must refuse ${JSON.stringify(bad)}`,
360+
).toBe(false);
361+
}
362+
});
363+
364+
it('accepts all four consented classes exactly as the manifest declaration spells them', () => {
365+
const full = { services: ['object'], hooks: ['record.beforeInsert'], network: ['api.acme.com'], fs: ['/tmp'] };
366+
const parsed = EnvironmentArtifactSchema.parse({ ...wireMinimal, grantedPermissions: { '@acme/plugin-crm': full } });
367+
expect(parsed.grantedPermissions?.['@acme/plugin-crm']).toEqual(full);
368+
});
369+
});
370+
371+
describe('key = the plugin manifest `id` — the documented assumption, with the residual risk pinned on the contract text', () => {
372+
// The schema cannot distinguish a manifest `id` from a control-plane
373+
// `package_id` (both are strings), so this is a pin on the CONTRACT TEXT a
374+
// producer reads: the key's own description must name the manifest `id` as
375+
// the key, name `package_id` as what it is not, and state absent vs `{}`.
376+
// If the description stops saying so, the assumption is no longer
377+
// documented on the surface that carries it.
378+
it('the key description names the manifest `id` as the key, rules out `package_id`, and states absent vs `{}`', () => {
379+
const description = EnvironmentArtifactSchema.shape.grantedPermissions.description ?? '';
380+
expect(description).toMatch(/keyed by the plugin manifest `id`/);
381+
expect(description).toMatch(/not the control-plane `package_id`/);
382+
expect(description).toMatch(/Absent = no consent record/);
383+
expect(description).toMatch(/`\{\}` = consent-bearing and consented to nothing/);
384+
});
385+
386+
it('any string key parses — which IS the residual risk: a `package_id`-shaped key is accepted and would simply never be looked up', () => {
387+
const parsed = EnvironmentArtifactSchema.parse({
388+
...wireMinimal,
389+
grantedPermissions: { pkg_01HABCDE: { services: ['object'] } },
390+
});
391+
expect(parsed.grantedPermissions).toEqual({ pkg_01HABCDE: { services: ['object'] } });
392+
});
393+
});
394+
});

packages/spec/src/system/environment-artifact.zod.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { z } from 'zod';
44
import { lazySchema } from '../shared/lazy-schema';
55
import { retiredKey } from '../shared/retired-key';
66
import { ObjectStackDefinitionSchema } from '../stack.zod';
7+
import { PluginPermissionsSchema } from '../kernel/manifest.zod';
78

89
/**
910
* # Environment Artifact Envelope
@@ -32,6 +33,13 @@ import { ObjectStackDefinitionSchema } from '../stack.zod';
3233
* `checksum`.
3334
* - **Deployment Config (NOT in this schema):** business DB coordinates,
3435
* credentials, environment identity, secrets. Injected at runtime.
36+
* - **Consent state (this schema, `grantedPermissions`, #14865):** the
37+
* install-time GRANTED permission set per plugin, keyed by the plugin
38+
* manifest `id` — written by the control plane at consent-compile time,
39+
* read by the loader at materialize time. Control-plane state re-emitted
40+
* on every artifact assembly, not compiled metadata: it sits beside
41+
* `metadata`, outside the `checksum` digest. Absent ≠ `{}` — see the
42+
* key's docblock.
3543
*
3644
* See {@link content/docs/concepts/north-star.mdx} §6.3 for the
3745
* runtime-inputs boundary.
@@ -116,6 +124,61 @@ export const EnvironmentArtifactSchema = lazySchema(() => z.object({
116124
*/
117125
metadata: ObjectStackDefinitionSchema,
118126

127+
/**
128+
* Install-time GRANTED permission set, per plugin, keyed by the plugin
129+
* manifest `id` (#14865 — the artifact-contract half of #11333 option A
130+
* and the #13457 batch ruling; ADR-0025 §3.5 step 2).
131+
*
132+
* - **Producer:** the cloud control plane's consent-compile step. The
133+
* install-consent flow persists the consented four-class set
134+
* `{ services, hooks, network, fs }` on
135+
* `sys_package_installation.granted_permissions`; when the control plane
136+
* assembles this envelope it copies that set here, one entry per
137+
* consent-bearing package, so the environment-local carrier ships it and
138+
* no runtime path ever reads `sys_package_installation` (ADR-0003 /
139+
* cloud ADR-0007).
140+
* - **Consumer:** the materialize-time loader, which hands each entry to
141+
* `PluginPermissionEnforcer.registerGrantedPermissions(pluginName, granted)`
142+
* (`packages/core/src/security/plugin-permission-enforcer.ts`) so a
143+
* third-party plugin runs under exactly the surface the installer
144+
* consented to — independent of what its manifest *requested*
145+
* (`ManifestSchema.permissions`).
146+
*
147+
* **Absent ≠ `{}`.** The key ABSENT means no consent record exists for
148+
* this environment (first-party / pure-metadata packages; an artifact
149+
* assembled before consent existed). An EMPTY map `{}` — or a per-plugin
150+
* entry `{}` — is a consent record that consented to NOTHING. Cloud writes
151+
* that distinction and the loader decides on it, so this key carries no
152+
* `.default({})` and never may: `{}` round-trips as `{}`, absence
153+
* round-trips as absence (pinned next to this file).
154+
*
155+
* **Key = the plugin manifest `id`**, NOT the control-plane `package_id` —
156+
* the manifest `id` is the identity the enforcer is queried with
157+
* (`AppPlugin` derives the kernel plugin name from `bundle.manifest.id`);
158+
* keying by `package_id` would need a second name→package resolution path.
159+
* Documented assumption, and the residual risk this contract accepts: a
160+
* package whose manifest `id` differs from its `package_id` is keyed by
161+
* the manifest `id` here, and a producer that keys such an entry by
162+
* `package_id` instead writes an entry the enforcer is never queried for —
163+
* the consented set then silently fails to bind to that plugin. This
164+
* schema cannot tell the two spellings apart; the producer owns it.
165+
*
166+
* Value shape is `PluginPermissionsSchema` (`kernel/manifest.zod.ts`,
167+
* `.strict()`) — the same declaration the manifest's *requested* set uses,
168+
* so granted ⊆ requested is expressible key-for-key, and an unknown
169+
* permission class is refused at the artifact door rather than granted
170+
* silently. Sits beside `metadata`, outside the `checksum` digest (which
171+
* covers the `metadata` block only).
172+
*/
173+
grantedPermissions: z.record(z.string(), PluginPermissionsSchema).optional()
174+
.describe(
175+
'Install-time GRANTED permission set per plugin, keyed by the plugin manifest `id` '
176+
+ '(not the control-plane `package_id`). Written by the cloud control plane at consent-compile '
177+
+ 'time from `sys_package_installation.granted_permissions`; consumed by the materialize-time '
178+
+ 'loader via `PluginPermissionEnforcer.registerGrantedPermissions`. Absent = no consent record; '
179+
+ '`{}` = consent-bearing and consented to nothing — the two are never collapsed.',
180+
),
181+
119182
// ── Retired v0 keys (#4740, ADR-0049) ──────────────────────────────
120183
// Declared-but-never-implemented in the pre-convergence ./system shape.
121184
// Tombstoned (not silently stripped) so a producer that authors one gets

0 commit comments

Comments
 (0)