@ultimat3/manifest 7.0.0 → 8.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
@@ -33,7 +33,16 @@ by the CLI, not imported.
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
35
  - Top-level key order in the file is fixed by `KEY_ORDER` in `emit.ts`.
36
- - `buildId` = sha256 of the canonical body. Verifiable from the file alone.
36
+ - `buildId` = sha256 of `@ultimat3/core`'s `canonicalJson` over the body the framework's one
37
+ INJECTIVE form, and the same one every `diff-*.ts` equality is taken over. It was a local
38
+ `JSON.stringify(sortKeys(v))` until 2026-08-22, exported from `index.ts` as `canonical`
39
+ (**removed, breaking**): that form spells `-0` as `0` and `NaN`/`±Infinity` as `null`, so a
40
+ default a client is told to expect could move and the diff answered "no change". Ordinary JSON
41
+ is byte-identical between the two — both tracked apps' committed manifests hash to the same id
42
+ they already carried — so the swap is observable only where the old form folded. **The published
43
+ document is `manifestJson` and is still `JSON.stringify` with a fixed key order**: an injective
44
+ form emits tokens JSON cannot parse, which is why `@ultimat3/action`'s `stableStringify` exists
45
+ as a separate function and why this one may not be written to disk.
37
46
  - Job `steps` keep declared order. Everything else sorts.
38
47
  - `permissions` is derived, never a second declared list — and derived from each operation's own
39
48
  `permissions`, **never from `policy`**. `policy` is a DISPLAY label: a composite renders as
package/README.md CHANGED
@@ -45,7 +45,7 @@ bytes. Enforced, not hoped for:
45
45
  filesystem.
46
46
  - Object keys are written in a fixed order, not `JSON.stringify` order, so reordering a
47
47
  struct literal produces no diff.
48
- - `buildId` is a sha256 of the canonical body, so it changes if and only if a fact changed —
48
+ - `buildId` is a sha256 of `canonicalJson` (`@ultimat3/core`) over the body, so it changes if and only if a fact changed —
49
49
  and `verifyBuildId()` re-derives it from the file, catching a hand edit.
50
50
  - Job `steps` keep declared order. A job's steps are a sequence, not a set.
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/manifest",
3
- "version": "7.0.0",
3
+ "version": "8.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": "7.0.0",
35
- "@ultimat3/core": "7.0.0",
36
- "@ultimat3/entity": "7.0.0",
37
- "@ultimat3/jobs": "7.0.0",
38
- "@ultimat3/query": "7.0.0"
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"
39
39
  }
40
40
  }
package/src/build.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  // assembled per app — both outside what this tier may import — so the CLI supplies them and
17
17
  // this function stays pure and unit-testable.
18
18
 
19
+ import { canonicalJson } from '@ultimat3/core';
19
20
  import type {
20
21
  ActionFact,
21
22
  EntityFact,
@@ -90,30 +91,19 @@ export function buildManifest(sources: ManifestSources): Manifest {
90
91
  }
91
92
 
92
93
  /**
93
- * Content hash of the manifest body. Deliberately excludes `buildId` itself, and is computed
94
- * over the same canonical serialisation `emit.ts` writes so `buildId` is verifiable from
95
- * the file alone.
94
+ * Content hash of the manifest body. Deliberately excludes `buildId` itself, and is taken over
95
+ * `@ultimat3/core`'s `canonicalJson` — the same INJECTIVE form the diff compares on, so a fact
96
+ * that changed cannot hash the same as the fact it replaced.
97
+ *
98
+ * This is a HASH, never the published document: `manifestJson` in `emit.ts` is what reaches disk,
99
+ * and it is `JSON.stringify` with a fixed key order for exactly that reason.
96
100
  */
97
101
  export function contentHash(body: Omit<Manifest, 'buildId'>): string {
98
102
  const hasher = new Bun.CryptoHasher('sha256');
99
- hasher.update(canonical(body));
103
+ hasher.update(canonicalJson(body));
100
104
  return hasher.digest('hex').slice(0, 16);
101
105
  }
102
106
 
103
- /** Sorted-key JSON. Never `JSON.stringify(value)` directly — key order is not a contract. */
104
- export function canonical(value: unknown): string {
105
- return JSON.stringify(sortKeys(value));
106
- }
107
-
108
- function sortKeys(value: unknown): unknown {
109
- if (Array.isArray(value)) return value.map(sortKeys);
110
- if (typeof value !== 'object' || value === null) return value;
111
- const record = value as Record<string, unknown>;
112
- const out: Record<string, unknown> = {};
113
- for (const key of Object.keys(record).sort()) out[key] = sortKeys(record[key]);
114
- return out;
115
- }
116
-
117
107
  function sortBy<T>(items: readonly T[], key: (item: T) => string): readonly T[] {
118
108
  return [...items].sort((a, b) => {
119
109
  const ka = key(a);
@@ -1,7 +1,6 @@
1
1
  // The two callable surfaces — actions and queries — and the permissions they require.
2
2
 
3
- import { isMcpExposed } from '@ultimat3/core';
4
- import { canonical } from './build';
3
+ import { canonicalJson, isMcpExposed } from '@ultimat3/core';
5
4
  import type { ManifestChange } from './diff-change';
6
5
  import { index } from './diff-change';
7
6
  import { diffRateLimit } from './diff-rate-limit';
@@ -23,10 +22,10 @@ export function diffActions(
23
22
  changes.push({ kind: 'breaking', path, detail: 'action removed' });
24
23
  continue;
25
24
  }
26
- if (canonical(action.input) !== canonical(next.input)) {
25
+ if (canonicalJson(action.input) !== canonicalJson(next.input)) {
27
26
  changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
28
27
  }
29
- if (canonical(action.output) !== canonical(next.output)) {
28
+ if (canonicalJson(action.output) !== canonicalJson(next.output)) {
30
29
  changes.push({ kind: 'breaking', path: `${path}.output`, detail: 'output schema changed' });
31
30
  }
32
31
  if (action.policy !== next.policy) {
@@ -52,7 +51,7 @@ export function diffActions(
52
51
  }
53
52
  changes.push(...diffPermissions(path, action, next));
54
53
  changes.push(...diffRateLimit(path, action, next));
55
- if (canonical(action.cacheInvalidates) !== canonical(next.cacheInvalidates)) {
54
+ if (canonicalJson(action.cacheInvalidates) !== canonicalJson(next.cacheInvalidates)) {
56
55
  changes.push({
57
56
  kind: 'internal',
58
57
  path: `${path}.cacheInvalidates`,
@@ -83,7 +82,7 @@ export function diffQueries(
83
82
  changes.push({ kind: 'breaking', path, detail: 'query removed' });
84
83
  continue;
85
84
  }
86
- if (canonical(query.input) !== canonical(next.input)) {
85
+ if (canonicalJson(query.input) !== canonicalJson(next.input)) {
87
86
  changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
88
87
  }
89
88
  if (query.policy !== next.policy) {
@@ -104,7 +103,7 @@ export function diffQueries(
104
103
  }
105
104
  // The same fact as an action's `cacheInvalidates`, and the same class: which tag flushes a
106
105
  // read is not a caller's contract, but a reviewer has to see it move.
107
- if (canonical(query.cacheTags) !== canonical(next.cacheTags)) {
106
+ if (canonicalJson(query.cacheTags) !== canonicalJson(next.cacheTags)) {
108
107
  changes.push({ kind: 'internal', path: `${path}.cacheTags`, detail: 'cache tags changed' });
109
108
  }
110
109
  }
@@ -2,7 +2,7 @@
2
2
  // now answers JSON breaks every link to it — while the delivery facts (`render`, `offline`,
3
3
  // `hydrate`, `budget`, `revalidateTags`) are internal and reported.
4
4
 
5
- import { canonical } from './build';
5
+ import { canonicalJson } from '@ultimat3/core';
6
6
  import type { ManifestChange } from './diff-change';
7
7
  import { diffScalar, index } from './diff-change';
8
8
  import type { RouteFact } from './schema';
@@ -88,6 +88,6 @@ function diffJson(
88
88
  detail: string,
89
89
  ): readonly ManifestChange[] {
90
90
  if (before === undefined || after === undefined) return [];
91
- if (canonical(before) === canonical(after)) return [];
91
+ if (canonicalJson(before) === canonicalJson(after)) return [];
92
92
  return [{ kind, path, detail }];
93
93
  }
package/src/diff-work.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // nothing throws, the work simply stops happening — which is why a removal on either side is
3
3
  // breaking rather than a note in the diff.
4
4
 
5
- import { canonical } from './build';
5
+ import { canonicalJson } from '@ultimat3/core';
6
6
  import type { ManifestChange } from './diff-change';
7
7
  import { diffScalar, index } from './diff-change';
8
8
  import type { JobFact, TaskFact } from './schema';
@@ -23,7 +23,7 @@ export function diffJobs(
23
23
  changes.push({ kind: 'breaking', path, detail: 'job removed' });
24
24
  continue;
25
25
  }
26
- if (canonical(job.input) !== canonical(next.input)) {
26
+ if (canonicalJson(job.input) !== canonicalJson(next.input)) {
27
27
  changes.push({
28
28
  kind: 'breaking',
29
29
  path: `${path}.input`,
@@ -42,7 +42,7 @@ export function diffJobs(
42
42
  ),
43
43
  );
44
44
  changes.push(...diffRetry(path, job, next));
45
- if (canonical(job.steps) !== canonical(next.steps)) {
45
+ if (canonicalJson(job.steps) !== canonicalJson(next.steps)) {
46
46
  changes.push({
47
47
  kind: 'internal',
48
48
  path: `${path}.steps`,
@@ -118,7 +118,7 @@ export function diffTasks(
118
118
  changes.push(
119
119
  ...diffScalar('internal', `${path}.tz`, task.tz, next.tz, (a, b) => `${a} -> ${b}`),
120
120
  );
121
- if (canonical(task.enqueues) !== canonical(next.enqueues)) {
121
+ if (canonicalJson(task.enqueues) !== canonicalJson(next.enqueues)) {
122
122
  changes.push({ kind: 'internal', path: `${path}.enqueues`, detail: 'enqueued jobs changed' });
123
123
  }
124
124
  }
package/src/emit.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // so a refactor that reorders a struct literal does not produce a diff. Two-space indent and
5
5
  // a trailing newline: the file is reviewed by humans and diffed by git.
6
6
 
7
- import { canonical, contentHash } from './build';
7
+ import { canonicalJson } from '@ultimat3/core';
8
+ import { contentHash } from './build';
8
9
  import { ManifestDriftError } from './errors';
9
10
  import type { Manifest } from './schema';
10
11
  import { isManifest } from './schema';
@@ -127,7 +128,8 @@ function describeDrift(onDisk: Manifest, fresh: Manifest): readonly string[] {
127
128
  const differences: string[] = [];
128
129
  for (const key of KEY_ORDER) {
129
130
  if (key === 'buildId') continue;
130
- if (canonical(onDisk[key]) !== canonical(fresh[key])) differences.push(`${key} differs`);
131
+ if (canonicalJson(onDisk[key]) !== canonicalJson(fresh[key]))
132
+ differences.push(`${key} differs`);
131
133
  }
132
134
  return differences.length > 0 ? differences : ['buildId differs'];
133
135
  }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export {
10
10
  checkAgentsMd,
11
11
  } from './agents-md';
12
12
  export type { ManifestSources } from './build';
13
- export { buildManifest, canonical, contentHash } from './build';
13
+ export { buildManifest, contentHash } from './build';
14
14
  export type { ChangeKind, ManifestChange, ManifestDiff } from './diff';
15
15
  export { diffManifest, formatDiff } from './diff';
16
16
  export type { DocEntry, DocEntryKind } from './docs-scan';