@ultimat3/manifest 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -13,7 +13,15 @@ by the CLI, not imported.
13
13
  | `schema.ts` | the manifest's typed shape + `MANIFEST_VERSION` |
14
14
  | `build.ts` | `buildManifest` — pure, deterministic, stably sorted |
15
15
  | `sources.ts` | wires `describe*` from entity/action/query/jobs into `ManifestSources` |
16
- | `diff.ts` | `diffManifest` — breaking / additive / internal |
16
+ | `diff.ts` | `diffManifest` — the orchestrator: one classifier per section, nothing else |
17
+ | `diff-change.ts` | the shared vocabulary: `ManifestChange`, `index`, `diffNamedSet`, `diffScalar` |
18
+ | `diff-operations.ts` | actions, queries and the permissions they require |
19
+ | `diff-rate-limit.ts` | how a declared rate limit moved |
20
+ | `diff-entities.ts` | tables, columns, keys, invariants |
21
+ | `diff-work.ts` | jobs and tasks — the two things that fail by silently not happening |
22
+ | `diff-routes.ts` | a URL's surface and its delivery facts |
23
+ | `diff-registries.ts` | policies and error codes |
24
+ | `diff-fixtures.ts` | TEST-ONLY: one fully-populated `ManifestSources`. Never in `index.ts` |
17
25
  | `verify.ts` | `verifyContract` — the major-bump gate |
18
26
  | `emit.ts` | canonical serialisation, write, `--json`, drift check |
19
27
  | `agents-md.ts` | read-only AGENTS.md existence + size check |
@@ -69,8 +77,25 @@ by the CLI, not imported.
69
77
  - A guide topic is unique within its package — repeated headings take a document-order suffix.
70
78
  - Neither docs module may import a registry or a clock. `docs-search.ts` is pure; `docs-scan.ts`
71
79
  reads files and nothing else.
72
- - **A new manifest field ⇒ a `diff.ts` rule for it.** Always — a field nothing classifies is a
73
- fact the gate cannot see, which is the whole reason the field was added.
80
+ - **A new manifest field ⇒ a `diff-*.ts` rule for it.** Always — a field nothing classifies is a
81
+ fact the gate cannot see, which is the whole reason the field was added. Now enforced rather
82
+ than stated: `diff.test.ts` walks `ARRAY_SECTIONS` (exported by `schema.ts`, the same list
83
+ `isManifest` checks) and fails on a section emptying without a classified change. `tasks` and
84
+ `errorCodes` were unread until 2026-08, along with ten fields — a renamed table, a dropped
85
+ `primaryKey`/`references`, emptied `invariants`, a moved job `queue`, `retry.attempts` 5 → 0, a
86
+ route's `surface`/`offline`/`hydrate`, and a query's `cacheTags` — and every one of them
87
+ reported exactly `[{ kind: 'internal', path: 'buildId' }]` with `hasBreaking: false`. A new
88
+ SECTION needs its own file beside the others; a new FIELD joins the file its section owns.
89
+ - **The axis is what a change refuses, not how it reads.** Something that rejects input that was
90
+ valid yesterday is breaking (an invariant added, a NOT NULL, a gained permission, a gained
91
+ enforcement site, a lowered `retry.attempts`); something that accepts more is additive and
92
+ still reported (an invariant dropped, a permission dropped, more attempts). A removal is
93
+ breaking on every section, including the two that fail silently: a deleted task never runs
94
+ again and a job whose `queue` moved piles up where no worker is subscribed.
95
+ - **Absence is evidence only where absence has a meaning.** `primaryKey`/`references` absent IS
96
+ "no key", so a dropped one is classified. `surface`, `offline`, `hydrate`, `budget` and
97
+ `revalidateTags` absent means "this file predates the field", so `diffScalar` skips a side that
98
+ carries nothing rather than reporting every route in an upgraded app as re-surfaced.
74
99
  - **`MANIFEST_VERSION` bumps only when a reader built for the old version would be WRONG** — a
75
100
  field removed, retyped, or given a new meaning — never for one that is merely added. Two costs
76
101
  make the reflex expensive: `isCompatible` is an equality check, so a bump rejects every
package/README.md CHANGED
@@ -58,9 +58,19 @@ whole mechanism.
58
58
 
59
59
  | Class | Examples |
60
60
  |---|---|
61
- | **breaking** | action/query/route/job/entity removed; input or output schema changed; policy changed; **an operation gained a required permission**; **a rate limit was tightened or introduced**; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; live query became non-live |
62
- | **additive** | primitive added; nullable column added; a required permission dropped; a rate limit loosened or removed; MCP exposure granted; locale added |
63
- | **internal** | cache tags changed; render mode changed; job steps reordered; `buildId` |
61
+ | **breaking** | action/query/route/job/**task**/entity/**policy**/**error code** removed; input or output schema changed; policy changed; **an operation gained a required permission**; **a policy gained an enforcement site**; **a rate limit was tightened or introduced**; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; **a column's `primaryKey` or `references` changed in either direction**; **an entity's `table` renamed**; **an invariant added**; **a job's `queue` moved or its `retry.attempts` lowered**; **a route's `surface` changed**; live query became non-live |
62
+ | **additive** | primitive added; nullable column added; a required permission dropped; an enforcement site dropped; **an invariant dropped**; a rate limit loosened or removed; **more retry attempts**; MCP exposure granted; locale added |
63
+ | **internal** | cache tags changed (actions **and queries**); render mode changed; **a route's `offline`/`hydrate`/`budget`/`revalidateTags`**; **a task's `cron`/`tz`/`enqueues`**; **a job's `retry.backoff`**; job steps reordered; **an error code's owning package**; `buildId` |
64
+
65
+ Every top-level section is classified, and that is checked rather than promised:
66
+ `diff.test.ts` walks `ARRAY_SECTIONS` from `schema.ts` and fails on a section nothing reads. Two
67
+ of them — `tasks` and `errorCodes` — were unread until 2026-08, along with ten fields, so deleting
68
+ every scheduled task reported `internal buildId: content changed` and passed the gate.
69
+
70
+ One classifier per section, each in its own file: `diff-operations.ts` (actions, queries,
71
+ permissions), `diff-rate-limit.ts`, `diff-entities.ts`, `diff-work.ts` (jobs, tasks),
72
+ `diff-routes.ts`, `diff-registries.ts` (policies, error codes), over the shared vocabulary in
73
+ `diff-change.ts`. `diff.ts` is the orchestrator and nothing else.
64
74
 
65
75
  `verifyContract()` is the gate: a breaking change fails unless the app's **major** version
66
76
  moved. An unparseable version counts as "not bumped" — fail-closed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "3.0.0",
35
- "@ultimat3/core": "3.0.0",
36
- "@ultimat3/entity": "3.0.0",
37
- "@ultimat3/jobs": "3.0.0",
38
- "@ultimat3/query": "3.0.0"
34
+ "@ultimat3/action": "4.0.0",
35
+ "@ultimat3/core": "4.0.0",
36
+ "@ultimat3/entity": "4.0.0",
37
+ "@ultimat3/jobs": "4.0.0",
38
+ "@ultimat3/query": "4.0.0"
39
39
  }
40
40
  }
@@ -0,0 +1,67 @@
1
+ // The vocabulary every classifier in the `diff-*` family speaks: one change, its three kinds, and
2
+ // the two shapes every section needs — an index by name and a flat set difference.
3
+ //
4
+ // breaking — an existing consumer stops working (a removal, a tightened input, a changed
5
+ // output, a new or changed policy, a newly REQUIRED permission, a tightened rate
6
+ // limit — anything that already shipped and now refuses a caller it served)
7
+ // additive — a new capability; nothing that worked stops working
8
+ // internal — visible in the file but not in the contract (a description, a cache tag, the
9
+ // buildId itself)
10
+
11
+ export type ChangeKind = 'breaking' | 'additive' | 'internal';
12
+
13
+ export interface ManifestChange {
14
+ readonly kind: ChangeKind;
15
+ /** Dotted path into the manifest, e.g. `actions.publishPost.policy`. */
16
+ readonly path: string;
17
+ readonly detail: string;
18
+ }
19
+
20
+ export function index<T>(items: readonly T[], key: (item: T) => string): Map<string, T> {
21
+ return new Map(items.map((item) => [key(item), item]));
22
+ }
23
+
24
+ /**
25
+ * A flat set of names: what left, what arrived. `removalKind` is the only thing that differs
26
+ * between sections — a locale disappearing is additive, a permission disappearing is not.
27
+ */
28
+ export function diffNamedSet(
29
+ path: string,
30
+ before: readonly string[],
31
+ after: readonly string[],
32
+ removalKind: ChangeKind = 'breaking',
33
+ ): readonly ManifestChange[] {
34
+ const changes: ManifestChange[] = [];
35
+ const afterSet = new Set(after);
36
+ const beforeSet = new Set(before);
37
+ for (const name of before) {
38
+ if (!afterSet.has(name)) {
39
+ changes.push({ kind: removalKind, path: `${path}.${name}`, detail: 'removed' });
40
+ }
41
+ }
42
+ for (const name of after) {
43
+ if (!beforeSet.has(name)) {
44
+ changes.push({ kind: 'additive', path: `${path}.${name}`, detail: 'added' });
45
+ }
46
+ }
47
+ return changes;
48
+ }
49
+
50
+ /**
51
+ * One scalar field, compared only where BOTH sides carry a value.
52
+ *
53
+ * `before` is a file parsed off disk, so an optional field a manifest was written before is
54
+ * absent rather than wrong — and reading absence as a value reports every route in an upgraded
55
+ * app as newly re-surfaced. Absence is no evidence, the same rule `permissions` already follows.
56
+ */
57
+ export function diffScalar(
58
+ kind: ChangeKind,
59
+ path: string,
60
+ before: unknown,
61
+ after: unknown,
62
+ detail = (from: string, to: string): string => `${from} -> ${to}`,
63
+ ): readonly ManifestChange[] {
64
+ if (before === undefined || after === undefined || before === null || after === null) return [];
65
+ if (before === after) return [];
66
+ return [{ kind, path, detail: detail(String(before), String(after)) }];
67
+ }
@@ -0,0 +1,138 @@
1
+ // The data shape: which table an entity maps to, what its columns promise, and which rules the
2
+ // database itself enforces. The axis is the same one `nullable` already uses — a change that
3
+ // rejects something previously valid is breaking; one that accepts more is additive and reported.
4
+
5
+ import type { ManifestChange } from './diff-change';
6
+ import { diffScalar, index } from './diff-change';
7
+ import type { ColumnFact, EntityFact } from './schema';
8
+
9
+ export function diffEntities(
10
+ before: readonly EntityFact[],
11
+ after: readonly EntityFact[],
12
+ ): readonly ManifestChange[] {
13
+ const changes: ManifestChange[] = [];
14
+ const afterByName = index(after, (e) => e.name);
15
+ const beforeByName = index(before, (e) => e.name);
16
+
17
+ for (const entity of before) {
18
+ const next = afterByName.get(entity.name);
19
+ const path = `entities.${entity.name}`;
20
+ if (next === undefined) {
21
+ changes.push({ kind: 'breaking', path, detail: 'entity removed' });
22
+ continue;
23
+ }
24
+ // The table is the name every hand-written query, view and migration outside the app uses;
25
+ // a rename leaves the entity intact in the manifest and every one of them broken.
26
+ changes.push(
27
+ ...diffScalar(
28
+ 'breaking',
29
+ `${path}.table`,
30
+ entity.table,
31
+ next.table,
32
+ (from, to) => `table ${from} -> ${to}`,
33
+ ),
34
+ );
35
+ changes.push(...diffColumns(path, entity, next));
36
+ changes.push(...diffInvariants(`${path}.invariants`, entity.invariants, next.invariants));
37
+ }
38
+ for (const entity of after) {
39
+ if (!beforeByName.has(entity.name)) {
40
+ changes.push({ kind: 'additive', path: `entities.${entity.name}`, detail: 'entity added' });
41
+ }
42
+ }
43
+ return changes;
44
+ }
45
+
46
+ /**
47
+ * An invariant is a CHECK or UNIQUE the database itself enforces, so the direction decides:
48
+ * adding one rejects rows that were valid a moment ago, dropping one only widens what the table
49
+ * accepts. Both are reported — a rule that quietly stopped being enforced is what a reviewer of a
50
+ * data migration most needs to see.
51
+ */
52
+ function diffInvariants(
53
+ path: string,
54
+ before: readonly string[],
55
+ after: readonly string[],
56
+ ): readonly ManifestChange[] {
57
+ const beforeSet = new Set(before);
58
+ const afterSet = new Set(after);
59
+ return [
60
+ ...after
61
+ .filter((name) => !beforeSet.has(name))
62
+ .map((name) => ({
63
+ kind: 'breaking' as const,
64
+ path: `${path}.${name}`,
65
+ detail: 'invariant added; rows that were valid are now rejected',
66
+ })),
67
+ ...before
68
+ .filter((name) => !afterSet.has(name))
69
+ .map((name) => ({
70
+ kind: 'additive' as const,
71
+ path: `${path}.${name}`,
72
+ detail: 'invariant removed; the rule is no longer enforced',
73
+ })),
74
+ ];
75
+ }
76
+
77
+ function diffColumns(
78
+ path: string,
79
+ before: EntityFact,
80
+ after: EntityFact,
81
+ ): readonly ManifestChange[] {
82
+ const changes: ManifestChange[] = [];
83
+ const nextColumns = index(after.columns, (c) => c.name);
84
+
85
+ for (const column of before.columns) {
86
+ const next = nextColumns.get(column.name);
87
+ const at = `${path}.columns.${column.name}`;
88
+ if (next === undefined) {
89
+ changes.push({ kind: 'breaking', path: at, detail: 'column removed' });
90
+ continue;
91
+ }
92
+ if (column.type !== next.type) {
93
+ changes.push({
94
+ kind: 'breaking',
95
+ path: `${at}.type`,
96
+ detail: `${column.type} -> ${next.type}`,
97
+ });
98
+ }
99
+ if (column.nullable && !next.nullable) {
100
+ // Tightening nullability rejects rows that were valid a moment ago.
101
+ changes.push({ kind: 'breaking', path: `${at}.nullable`, detail: 'became NOT NULL' });
102
+ }
103
+ changes.push(...diffKey(at, 'primaryKey', keyOf(column), keyOf(next)));
104
+ changes.push(...diffKey(at, 'references', column.references, next.references));
105
+ }
106
+
107
+ const beforeColumns = index(before.columns, (c) => c.name);
108
+ for (const column of after.columns) {
109
+ if (!beforeColumns.has(column.name)) {
110
+ changes.push({
111
+ kind: column.nullable ? 'additive' : 'breaking',
112
+ path: `${path}.columns.${column.name}`,
113
+ detail: column.nullable ? 'column added' : 'NOT NULL column added with no default',
114
+ });
115
+ }
116
+ }
117
+ return changes;
118
+ }
119
+
120
+ /** `primaryKey` is optional in the file, so absence is the same statement as `false`. */
121
+ const keyOf = (column: ColumnFact): string => (column.primaryKey === true ? 'yes' : 'no');
122
+
123
+ /**
124
+ * Identity and relationship, in either direction. Gaining one rejects rows that used to insert;
125
+ * losing one strands every consumer that navigated the graph the manifest published — a foreign
126
+ * key is how an agent knows `authorId` reaches `users`, and nothing else in the file says so.
127
+ */
128
+ function diffKey(
129
+ at: string,
130
+ field: 'primaryKey' | 'references',
131
+ before: string | undefined,
132
+ after: string | undefined,
133
+ ): readonly ManifestChange[] {
134
+ const from = before ?? 'none';
135
+ const to = after ?? 'none';
136
+ if (from === to) return [];
137
+ return [{ kind: 'breaking', path: `${at}.${field}`, detail: `${field} ${from} -> ${to}` }];
138
+ }
@@ -0,0 +1,95 @@
1
+ // TEST-ONLY. One fully-populated `ManifestSources` for the diff suites, so every section carries a
2
+ // fact and every fact carries every field its type declares — a fixture with an empty `tasks` is
3
+ // how a section nothing classifies stays green. Never exported from `index.ts`.
4
+
5
+ import type { ManifestSources } from './build';
6
+ import { buildManifest } from './build';
7
+ import type { Manifest } from './schema';
8
+
9
+ export const fixtureAction = (
10
+ name: string,
11
+ policy: string,
12
+ expose = true,
13
+ permissions = [policy],
14
+ ) => ({
15
+ name,
16
+ input: { id: 'uuid' },
17
+ output: { ok: 'boolean' },
18
+ policy,
19
+ permissions,
20
+ cacheInvalidates: ['post'],
21
+ mcp: { expose },
22
+ });
23
+
24
+ export const fixtureQuery = (name: string, policy: string, permissions = [policy]) => ({
25
+ name,
26
+ input: {},
27
+ policy,
28
+ permissions,
29
+ live: true,
30
+ cacheTags: ['post'],
31
+ });
32
+
33
+ /**
34
+ * Built through a helper rather than written as `{ code: 'X_…' }`: `x verify`'s `errors` step
35
+ * reads a `code:` key with an `X_*` literal as a DECLARATION, and this file is not a test file, so
36
+ * the literal would have published a fixture's code into `framework.manifest.json` as one this
37
+ * package owns.
38
+ */
39
+ export const fixtureErrorCode = (code: string, owner: string) => ({ code, package: owner });
40
+
41
+ /** Every section non-empty, every optional field present. */
42
+ export const FIXTURE: ManifestSources = {
43
+ app: { name: 'acme', version: '1.4.2' },
44
+ routes: [
45
+ {
46
+ url: '/posts',
47
+ render: 'isr',
48
+ offline: 'precache',
49
+ hydrate: 'idle',
50
+ revalidateTags: ['post'],
51
+ budget: { js: '40kb', lcp: 2000 },
52
+ surface: 'site',
53
+ },
54
+ ],
55
+ entities: [
56
+ {
57
+ name: 'post',
58
+ table: 'posts',
59
+ columns: [
60
+ { name: 'id', type: 'uuid', nullable: false, primaryKey: true },
61
+ { name: 'authorId', type: 'uuid', nullable: false, references: 'users.id' },
62
+ { name: 'note', type: 'text', nullable: true },
63
+ ],
64
+ invariants: ['post_title_present'],
65
+ },
66
+ ],
67
+ actions: [fixtureAction('publishPost', 'post:publish')],
68
+ queries: [fixtureQuery('feed', 'feed:read')],
69
+ jobs: [
70
+ {
71
+ name: 'sendMail',
72
+ input: { orgId: 'uuid' },
73
+ queue: 'critical',
74
+ retry: { attempts: 5, backoff: 'exponential' },
75
+ steps: ['a'],
76
+ },
77
+ ],
78
+ tasks: [
79
+ { name: 'nightlyDigest', cron: '0 3 * * *', tz: 'Europe/Berlin', enqueues: ['sendMail'] },
80
+ ],
81
+ policies: [
82
+ { permission: 'post:publish', description: 'publish a draft', enforcedIn: ['actions.publish'] },
83
+ ],
84
+ locales: ['en'],
85
+ // A code that is genuinely REGISTERED, not an invented one. `error-catalog.test.ts` scans every
86
+ // non-test file under `packages/*/src` for an `X_*` literal and treats an unregistered one as a
87
+ // code handed to a reader that no gate can see. A fixture is not shipped source in spirit, but
88
+ // it is in fact — and widening that scanner to excuse a filename is a worse trade than picking a
89
+ // real code here, since the diff classifier only ever compares the string.
90
+ errorCodes: [fixtureErrorCode('X_NOT_FOUND', 'app')],
91
+ };
92
+
93
+ /** The fixture, built. */
94
+ export const fixtureManifest = (overrides: Partial<ManifestSources> = {}): Manifest =>
95
+ buildManifest({ ...FIXTURE, ...overrides });
@@ -0,0 +1,176 @@
1
+ // The two callable surfaces — actions and queries — and the permissions they require.
2
+
3
+ import { isMcpExposed } from '@ultimat3/core';
4
+ import { canonical } from './build';
5
+ import type { ManifestChange } from './diff-change';
6
+ import { index } from './diff-change';
7
+ import { diffRateLimit } from './diff-rate-limit';
8
+ import type { ActionFact, QueryFact } from './schema';
9
+
10
+ export function diffActions(
11
+ before: readonly ActionFact[],
12
+ after: readonly ActionFact[],
13
+ ): readonly ManifestChange[] {
14
+ const changes: ManifestChange[] = [];
15
+ const afterByName = index(after, (a) => a.name);
16
+ const beforeByName = index(before, (a) => a.name);
17
+
18
+ for (const action of before) {
19
+ const next = afterByName.get(action.name);
20
+ const path = `actions.${action.name}`;
21
+ if (next === undefined) {
22
+ // The canonical breaking change: a caller that compiled yesterday no longer does.
23
+ changes.push({ kind: 'breaking', path, detail: 'action removed' });
24
+ continue;
25
+ }
26
+ if (canonical(action.input) !== canonical(next.input)) {
27
+ changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
28
+ }
29
+ if (canonical(action.output) !== canonical(next.output)) {
30
+ changes.push({ kind: 'breaking', path: `${path}.output`, detail: 'output schema changed' });
31
+ }
32
+ if (action.policy !== next.policy) {
33
+ changes.push({
34
+ kind: 'breaking',
35
+ path: `${path}.policy`,
36
+ detail: `policy ${action.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
37
+ });
38
+ }
39
+ // Through `isMcpExposed`, not the raw field: `before` is a file parsed from disk, so an
40
+ // older or hand-trimmed manifest can carry an absent, `null` or non-boolean `expose` that
41
+ // `!==` would read as a change and classify from. One predicate, the same one the tool
42
+ // projection asks, is what makes this verdict match what the surface actually serves.
43
+ const exposed = isMcpExposed(action.mcp);
44
+ const nextExposed = isMcpExposed(next.mcp);
45
+ if (exposed !== nextExposed) {
46
+ // Widening the surface is additive; withdrawing a tool an agent depends on is not.
47
+ changes.push({
48
+ kind: nextExposed ? 'additive' : 'breaking',
49
+ path: `${path}.mcp.expose`,
50
+ detail: `mcp exposure ${String(exposed)} -> ${String(nextExposed)}`,
51
+ });
52
+ }
53
+ changes.push(...diffPermissions(path, action, next));
54
+ changes.push(...diffRateLimit(path, action, next));
55
+ if (canonical(action.cacheInvalidates) !== canonical(next.cacheInvalidates)) {
56
+ changes.push({
57
+ kind: 'internal',
58
+ path: `${path}.cacheInvalidates`,
59
+ detail: 'cache tags changed',
60
+ });
61
+ }
62
+ }
63
+ for (const action of after) {
64
+ if (!beforeByName.has(action.name)) {
65
+ changes.push({ kind: 'additive', path: `actions.${action.name}`, detail: 'action added' });
66
+ }
67
+ }
68
+ return changes;
69
+ }
70
+
71
+ export function diffQueries(
72
+ before: readonly QueryFact[],
73
+ after: readonly QueryFact[],
74
+ ): readonly ManifestChange[] {
75
+ const changes: ManifestChange[] = [];
76
+ const afterByName = index(after, (q) => q.name);
77
+ const beforeByName = index(before, (q) => q.name);
78
+
79
+ for (const query of before) {
80
+ const next = afterByName.get(query.name);
81
+ const path = `queries.${query.name}`;
82
+ if (next === undefined) {
83
+ changes.push({ kind: 'breaking', path, detail: 'query removed' });
84
+ continue;
85
+ }
86
+ if (canonical(query.input) !== canonical(next.input)) {
87
+ changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
88
+ }
89
+ if (query.policy !== next.policy) {
90
+ changes.push({
91
+ kind: 'breaking',
92
+ path: `${path}.policy`,
93
+ detail: `policy ${query.policy ?? 'none'} -> ${next.policy ?? 'none'}`,
94
+ });
95
+ }
96
+ changes.push(...diffPermissions(path, query, next));
97
+ if (query.live !== next.live) {
98
+ // Losing live-ness breaks subscribers; gaining it breaks nobody.
99
+ changes.push({
100
+ kind: next.live ? 'additive' : 'breaking',
101
+ path: `${path}.live`,
102
+ detail: `live ${String(query.live)} -> ${String(next.live)}`,
103
+ });
104
+ }
105
+ // The same fact as an action's `cacheInvalidates`, and the same class: which tag flushes a
106
+ // read is not a caller's contract, but a reviewer has to see it move.
107
+ if (canonical(query.cacheTags) !== canonical(next.cacheTags)) {
108
+ changes.push({ kind: 'internal', path: `${path}.cacheTags`, detail: 'cache tags changed' });
109
+ }
110
+ }
111
+ for (const query of after) {
112
+ if (!beforeByName.has(query.name)) {
113
+ changes.push({ kind: 'additive', path: `queries.${query.name}`, detail: 'query added' });
114
+ }
115
+ }
116
+ return changes;
117
+ }
118
+
119
+ /**
120
+ * The permissions an operation REQUIRES, and the direction each move points.
121
+ *
122
+ * Gaining one is breaking: every caller holding yesterday's grant set is refused by an operation
123
+ * that served them, and the failure arrives at runtime as a 403 with nothing in the build that
124
+ * said so. Losing one is additive — nothing that worked stops working — but it is still reported,
125
+ * because a grant quietly dropped from an operation is a widening of access a reviewer has to see.
126
+ *
127
+ * Matched on `permissions`, never `policy`: `policy` is a display label, and a composite's label
128
+ * (`and(post:publish, org:administer)`) equals no permission, so a rule reading it would call
129
+ * every non-trivially-guarded operation unchanged while both of its real grants moved.
130
+ */
131
+ function diffPermissions(
132
+ path: string,
133
+ before: ActionFact | QueryFact,
134
+ after: ActionFact | QueryFact,
135
+ ): readonly ManifestChange[] {
136
+ const declared = readPermissions(before);
137
+ const next = readPermissions(after);
138
+ // Absence is no evidence, on either side. Unlike `mcp.expose` there is no value to fold it
139
+ // into: `[]` asserts "this operation requires nothing", so reading an absent field as `[]`
140
+ // would report every permission of every operation as newly required the first time an app
141
+ // diffs against a manifest written before the field existed — a wall of false breakings for
142
+ // an upgrade that changed no authorization at all.
143
+ if (declared === undefined || next === undefined) return [];
144
+
145
+ const changes: ManifestChange[] = [];
146
+ const declaredSet = new Set(declared);
147
+ const nextSet = new Set(next);
148
+ for (const permission of next) {
149
+ if (!declaredSet.has(permission)) {
150
+ changes.push({
151
+ kind: 'breaking',
152
+ path: `${path}.permissions.${permission}`,
153
+ detail: 'now required; callers granted the old set are refused',
154
+ });
155
+ }
156
+ }
157
+ for (const permission of declared) {
158
+ if (!nextSet.has(permission)) {
159
+ changes.push({
160
+ kind: 'additive',
161
+ path: `${path}.permissions.${permission}`,
162
+ detail: 'no longer required; access widened',
163
+ });
164
+ }
165
+ }
166
+ return changes;
167
+ }
168
+
169
+ /** The list as the FILE carries it, or `undefined` when it carries nothing this can compare. */
170
+ function readPermissions(fact: ActionFact | QueryFact): readonly string[] | undefined {
171
+ const value: unknown = fact.permissions;
172
+ if (!Array.isArray(value)) return undefined;
173
+ return value.every((entry) => typeof entry === 'string')
174
+ ? (value as readonly string[])
175
+ : undefined;
176
+ }
@@ -0,0 +1,85 @@
1
+ // One rule: how a declared rate limit moved. A tightened limit refuses a caller the old pair
2
+ // served, which is the definition of breaking — and it is the one contract change that leaves
3
+ // every schema in the manifest untouched, so nothing else in the diff can see it.
4
+
5
+ import type { ManifestChange } from './diff-change';
6
+ import type { ActionFact, RateLimitFact } from './schema';
7
+
8
+ /** No declaration at all, a declaration, or one this reader cannot make sense of. */
9
+ type RateLimitReading = RateLimitFact | 'none' | 'unreadable';
10
+
11
+ /**
12
+ * Introducing a limit where there was none is the same event at its extreme: a client that was
13
+ * never throttled now can be.
14
+ */
15
+ export function diffRateLimit(
16
+ path: string,
17
+ before: ActionFact,
18
+ after: ActionFact,
19
+ ): readonly ManifestChange[] {
20
+ const declared = readRateLimit(before);
21
+ const next = readRateLimit(after);
22
+ if (declared === 'unreadable' || next === 'unreadable') return [];
23
+ const at = `${path}.rateLimit`;
24
+
25
+ if (declared === 'none') {
26
+ if (next === 'none') return [];
27
+ return [
28
+ {
29
+ kind: 'breaking',
30
+ path: at,
31
+ detail: `rate limit introduced (${render(next)}); an unthrottled caller can now be refused`,
32
+ },
33
+ ];
34
+ }
35
+ if (next === 'none') {
36
+ return [{ kind: 'additive', path: at, detail: `rate limit removed (was ${render(declared)})` }];
37
+ }
38
+ if (tighter(declared, next)) {
39
+ return [
40
+ {
41
+ kind: 'breaking',
42
+ path: at,
43
+ detail: `rate limit tightened ${render(declared)} -> ${render(next)}; callers at the old rate are refused`,
44
+ },
45
+ ];
46
+ }
47
+ if (tighter(next, declared)) {
48
+ return [
49
+ {
50
+ kind: 'additive',
51
+ path: at,
52
+ detail: `rate limit loosened ${render(declared)} -> ${render(next)}`,
53
+ },
54
+ ];
55
+ }
56
+ return [];
57
+ }
58
+
59
+ /**
60
+ * Both halves, because either one alone refuses somebody: `limit` is the burst a caller may
61
+ * spend at once and `limit / windowMs` is the rate it refills at, so a larger burst on a slower
62
+ * refill still turns away a client the old pair served. Cross-multiplied rather than divided —
63
+ * both windows are positive, and an exact integer comparison cannot invent a change out of a
64
+ * rounding difference in a file that is diffed on every build.
65
+ */
66
+ const tighter = (from: RateLimitFact, to: RateLimitFact): boolean =>
67
+ to.limit < from.limit || to.limit * from.windowMs < from.limit * to.windowMs;
68
+
69
+ const render = (limit: RateLimitFact): string => `${limit.limit}/${limit.windowMs}ms`;
70
+
71
+ function readRateLimit(fact: ActionFact): RateLimitReading {
72
+ const value: unknown = fact.rateLimit;
73
+ if (value === undefined || value === null) return 'none';
74
+ if (typeof value !== 'object') return 'unreadable';
75
+ const record = value as Record<string, unknown>;
76
+ const limit = record['limit'];
77
+ const windowMs = record['windowMs'];
78
+ // The same two conditions `toBucket` enforces at mount: a non-positive window is an infinite
79
+ // refill and a sub-token limit closes the endpoint, so neither describes a limit to compare.
80
+ if (!positive(limit) || !positive(windowMs)) return 'unreadable';
81
+ return { limit, windowMs };
82
+ }
83
+
84
+ const positive = (value: unknown): value is number =>
85
+ typeof value === 'number' && Number.isFinite(value) && value > 0;