@ultimat3/manifest 8.0.0 → 10.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
@@ -32,7 +32,18 @@ by the CLI, not imported.
32
32
 
33
33
  - **No nondeterminism.** No timestamp, git sha, hostname, counter, or unsorted iteration.
34
34
  `buildManifest` is pure — it must never read a registry, a clock, or the filesystem.
35
- - Top-level key order in the file is fixed by `KEY_ORDER` in `emit.ts`.
35
+ - Top-level key order in the file is fixed by `KEY_ORDER` in `emit.ts` — `as const satisfies
36
+ readonly (keyof Manifest)[]` AND walked by `emit.test.ts`, the treatment `ARRAY_SECTIONS` has,
37
+ because the annotation catches a key that is not on `Manifest` and only the walk catches one that
38
+ is MISSING. `manifestJson` writes those keys and no others while `contentHash` hashes the whole
39
+ body, so a 14th field would go into the hash and be dropped from the file — after which
40
+ `assertNoDrift` convicts the committed manifest as HAND_EDITED, a correct refusal with the wrong
41
+ diagnosis, about a file nobody touched.
42
+ - **Every author-declared inner list is sorted**, `routes[].revalidateTags` included (`As of
43
+ 2026-08-23`). It was the one collection `buildManifest` left in declaration order, so reordering
44
+ `revalidate: [tag.b, tag.a]` in a route file churned `buildId` and emitted a
45
+ `routes.<url>.revalidateTags` change nothing had changed. `jobs[].steps` is the one exception and
46
+ says so at its own declaration: steps are a sequence, not a set.
36
47
  - `buildId` = sha256 of `@ultimat3/core`'s `canonicalJson` over the body — the framework's one
37
48
  INJECTIVE form, and the same one every `diff-*.ts` equality is taken over. It was a local
38
49
  `JSON.stringify(sortKeys(v))` until 2026-08-22, exported from `index.ts` as `canonical`
@@ -98,7 +109,9 @@ by the CLI, not imported.
98
109
  - **The axis is what a change refuses, not how it reads.** Something that rejects input that was
99
110
  valid yesterday is breaking (an invariant added, a NOT NULL, a gained permission, a gained
100
111
  enforcement site, a lowered `retry.attempts`); something that accepts more is additive and
101
- still reported (an invariant dropped, a permission dropped, more attempts). A removal is
112
+ still reported (an invariant dropped, a permission dropped, more attempts, **a column that lost
113
+ NOT NULL** — `diffColumns` implemented only the tightening half until 2026-08-23, so a loosened
114
+ column was reported as nothing at all). A removal is
102
115
  breaking on every section, including the two that fail silently: a deleted task never runs
103
116
  again and a job whose `queue` moved piles up where no worker is subscribed.
104
117
  - **Absence is evidence only where absence has a meaning.** `primaryKey`/`references` absent IS
package/README.md CHANGED
@@ -40,7 +40,8 @@ The file is committed and reviewed, so two builds of the same tree must produce
40
40
  bytes. Enforced, not hoped for:
41
41
 
42
42
  - **No timestamps, no git sha, no hostname, no build counter.**
43
- - Every collection is sorted by a stable key before writing `Map`/`Set` iteration order is
43
+ - Every collection is sorted by a stable key before writing, inner lists included (a route's
44
+ `revalidateTags`, an action's `cacheInvalidates`, a task's `enqueues`) — `Map`/`Set` iteration order is
44
45
  insertion order, and insertion order depends on module load order, which depends on the
45
46
  filesystem.
46
47
  - Object keys are written in a fixed order, not `JSON.stringify` order, so reordering a
@@ -59,7 +60,7 @@ whole mechanism.
59
60
  | Class | Examples |
60
61
  |---|---|
61
62
  | **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
+ | **additive** | primitive added; nullable column added; **a column that lost NOT NULL**; 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
64
  | **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
 
65
66
  Every top-level section is classified, and that is checked rather than promised:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "8.0.0",
3
+ "version": "10.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": "8.0.0",
35
- "@ultimat3/core": "8.0.0",
36
- "@ultimat3/entity": "8.0.0",
37
- "@ultimat3/jobs": "8.0.0",
38
- "@ultimat3/query": "8.0.0"
34
+ "@ultimat3/action": "10.0.0",
35
+ "@ultimat3/core": "10.0.0",
36
+ "@ultimat3/entity": "10.0.0",
37
+ "@ultimat3/jobs": "10.0.0",
38
+ "@ultimat3/query": "10.0.0"
39
39
  }
40
40
  }
package/src/build.ts CHANGED
@@ -44,7 +44,7 @@ export interface ManifestSources {
44
44
  }
45
45
 
46
46
  export function buildManifest(sources: ManifestSources): Manifest {
47
- const routes = sortBy(sources.routes ?? [], (r) => r.url);
47
+ const routes = sortBy(sources.routes ?? [], (r) => r.url).map(normalizeRoute);
48
48
  const entities = sortBy(sources.entities ?? [], (e) => e.name).map(normalizeEntity);
49
49
  const actions = sortBy(sources.actions ?? [], (a) => a.name).map(normalizeAction);
50
50
  const queries = sortBy(sources.queries ?? [], (q) => q.name).map(normalizeQuery);
@@ -117,6 +117,18 @@ function unique(values: readonly string[]): readonly string[] {
117
117
  }
118
118
 
119
119
  // Inner collections are sorted too: a reordered column list is a spurious diff.
120
+ // `revalidateTags` is an author-declared SET — the same kind of list as an action's
121
+ // `cacheInvalidates`, which has always been sorted here. Left in declaration order it was the one
122
+ // inner collection `buildManifest` did not normalise, so reordering `revalidate: [tag.b, tag.a]`
123
+ // in a route file churned `buildId` and emitted a spurious `routes.<url>.revalidateTags` change.
124
+ // `jobs[].steps` is the one deliberate exception, and says why above itself.
125
+ const normalizeRoute = (route: RouteFact): RouteFact =>
126
+ route.revalidateTags === undefined
127
+ ? route
128
+ : // Spread, never assigned: `exactOptionalPropertyTypes` makes an explicit `undefined` a
129
+ // different answer from an absent key, and `emit.ts` writes what it is given.
130
+ { ...route, revalidateTags: [...route.revalidateTags].sort() };
131
+
120
132
  const normalizeEntity = (entity: EntityFact): EntityFact => ({
121
133
  ...entity,
122
134
  columns: sortBy(entity.columns, (c) => c.name),
@@ -96,9 +96,17 @@ function diffColumns(
96
96
  detail: `${column.type} -> ${next.type}`,
97
97
  });
98
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' });
99
+ if (column.nullable !== next.nullable) {
100
+ // Both directions, the axis this file's header declares and `diffInvariants` already
101
+ // implements: tightening rejects rows that were valid a moment ago, loosening only widens
102
+ // what the table accepts — and a constraint that quietly stopped being enforced is what a
103
+ // reviewer of a data migration most needs to see. Only the tightening half was here, so
104
+ // dropping NOT NULL reported nothing at all.
105
+ changes.push(
106
+ next.nullable
107
+ ? { kind: 'additive', path: `${at}.nullable`, detail: 'became nullable' }
108
+ : { kind: 'breaking', path: `${at}.nullable`, detail: 'became NOT NULL' },
109
+ );
102
110
  }
103
111
  changes.push(...diffKey(at, 'primaryKey', keyOf(column), keyOf(next)));
104
112
  changes.push(...diffKey(at, 'references', column.references, next.references));
package/src/emit.ts CHANGED
@@ -12,8 +12,19 @@ import { isManifest } from './schema';
12
12
 
13
13
  export const MANIFEST_FILENAME = 'x.manifest.json';
14
14
 
15
- /** Top-level key order. Explicit so the file reads in a sensible order every time. */
16
- const KEY_ORDER: readonly (keyof Manifest)[] = [
15
+ /**
16
+ * Top-level key order. Explicit so the file reads in a sensible order every time.
17
+ *
18
+ * `as const satisfies` and a test that WALKS it, the treatment `ARRAY_SECTIONS` already has
19
+ * (`schema.ts`) — the annotation catches a key that is not on `Manifest`, only a walk catches one
20
+ * that is missing. It was a bare annotation, and `manifestJson` writes these keys and no others
21
+ * while `contentHash` hashes the whole body: a 14th field added to `Manifest` would have gone into
22
+ * the hash and been dropped from the file, after which `assertNoDrift` convicts the committed
23
+ * manifest as HAND_EDITED — a correct refusal carrying the wrong diagnosis, about a file nobody
24
+ * touched. Exported for that test alone; deliberately NOT re-exported by `src/index.ts`, because
25
+ * the ORDER is this module's business and a public one would be semver-locked.
26
+ */
27
+ export const KEY_ORDER = [
17
28
  'manifestVersion',
18
29
  'buildId',
19
30
  'app',
@@ -27,7 +38,7 @@ const KEY_ORDER: readonly (keyof Manifest)[] = [
27
38
  'permissions',
28
39
  'locales',
29
40
  'errorCodes',
30
- ];
41
+ ] as const satisfies readonly (keyof Manifest)[];
31
42
 
32
43
  /** The exact bytes written to disk. Deterministic for a given manifest. */
33
44
  export function manifestJson(manifest: Manifest): string {
package/src/errors.ts CHANGED
@@ -28,7 +28,12 @@ registerErrorCodes(
28
28
  ),
29
29
  );
30
30
 
31
- const docsFor = (code: ManifestErrorCode): string => `https://ultimate.dev/errors/${code}`;
31
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
32
+ // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
33
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
34
+ // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
35
+ // answered 404, host included, on every error it has ever thrown; restating the replacement here
36
+ // would be the same constant in eight places waiting to drift again.
32
37
 
33
38
  /**
34
39
  * The committed `x.manifest.json` no longer matches the code. Drift means an agent reading
@@ -40,7 +45,6 @@ export class ManifestDriftError extends UltimateError {
40
45
  code: 'X_MANIFEST_DRIFT',
41
46
  cause: `${input.path} is stale: ${summarize(input.differences)}`,
42
47
  fix: 'x manifest',
43
- docs: docsFor('X_MANIFEST_DRIFT'),
44
48
  });
45
49
  }
46
50
  }
@@ -54,7 +58,6 @@ export class ManifestBreakingError extends UltimateError {
54
58
  `${input.changes.length} breaking change(s) from ${input.from} to ${input.to} ` +
55
59
  `with no major version bump: ${summarize(input.changes)}`,
56
60
  fix: 'bump the major version in app.config.ts, or restore the removed contract',
57
- docs: docsFor('X_MANIFEST_BREAKING'),
58
61
  });
59
62
  }
60
63
  }
@@ -69,7 +72,6 @@ export class AgentsMdMissingError extends UltimateError {
69
72
  code: 'X_AGENTS_MD_MISSING',
70
73
  cause: `${input.path} does not exist`,
71
74
  fix: `create ${input.path} by hand: stack, commands, conventions. Keep it short; facts live in x.manifest.json`,
72
- docs: docsFor('X_AGENTS_MD_MISSING'),
73
75
  });
74
76
  }
75
77
  }
@@ -81,7 +83,6 @@ export class AgentsMdTooLargeError extends UltimateError {
81
83
  code: 'X_AGENTS_MD_TOO_LARGE',
82
84
  cause: `${input.path} is ${input.bytes}B, over the ${input.maxBytes}B budget`,
83
85
  fix: 'move generated facts out of AGENTS.md and let x.manifest.json carry them',
84
- docs: docsFor('X_AGENTS_MD_TOO_LARGE'),
85
86
  });
86
87
  }
87
88
  }