@sentropic/artifacts 0.1.4 → 0.2.1

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.
Files changed (69) hide show
  1. package/README.md +47 -0
  2. package/dist/adapters/postgres-commit.d.ts +15 -0
  3. package/dist/adapters/postgres-commit.js +80 -0
  4. package/dist/adapters/postgres.d.ts +5 -2
  5. package/dist/adapters/postgres.js +39 -0
  6. package/dist/http.d.ts +43 -30
  7. package/dist/http.js +158 -507
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/mcp-core.d.ts +32 -39
  11. package/dist/mcp-core.js +83 -244
  12. package/dist/mcp.js +5 -5
  13. package/dist/profile/contracts.d.ts +67 -0
  14. package/dist/profile/contracts.js +31 -0
  15. package/dist/profile/index.d.ts +6 -0
  16. package/dist/profile/index.js +117 -0
  17. package/dist/profile/json.d.ts +10 -0
  18. package/dist/profile/json.js +76 -0
  19. package/dist/profile/radar.d.ts +3 -0
  20. package/dist/profile/radar.js +21 -0
  21. package/dist/profile/schemas.d.ts +6 -0
  22. package/dist/profile/schemas.js +76 -0
  23. package/dist/profile/validation.d.ts +5 -0
  24. package/dist/profile/validation.js +104 -0
  25. package/dist/profile/validators.d.ts +3 -0
  26. package/dist/profile/validators.js +51 -0
  27. package/dist/server/authorized-replay.d.ts +33 -0
  28. package/dist/server/authorized-replay.js +31 -0
  29. package/dist/server/command-identity.d.ts +52 -0
  30. package/dist/server/command-identity.js +71 -0
  31. package/dist/server/commit/brands.d.ts +7 -0
  32. package/dist/server/commit/brands.js +3 -0
  33. package/dist/server/commit/coordinator.d.ts +2 -0
  34. package/dist/server/commit/coordinator.js +729 -0
  35. package/dist/server/commit/types.d.ts +382 -0
  36. package/dist/server/commit/types.js +94 -0
  37. package/dist/server/commit-contract.d.ts +4 -0
  38. package/dist/server/commit-contract.js +2 -0
  39. package/dist/server/failures.d.ts +84 -0
  40. package/dist/server/failures.js +133 -0
  41. package/dist/server/gc.d.ts +42 -1
  42. package/dist/server/gc.js +168 -5
  43. package/dist/server/idempotency.d.ts +4 -0
  44. package/dist/server/idempotency.js +14 -0
  45. package/dist/server/lifecycle.d.ts +2 -1
  46. package/dist/server/lifecycle.js +15 -1
  47. package/dist/server/object-intents.d.ts +14 -1
  48. package/dist/server/object-intents.js +31 -0
  49. package/dist/server/outcome-contract.d.ts +3 -0
  50. package/dist/server/outcome-contract.js +3 -0
  51. package/dist/server/outcome-projection.d.ts +21 -0
  52. package/dist/server/outcome-projection.js +29 -0
  53. package/dist/server/preparation-cleanup.d.ts +31 -0
  54. package/dist/server/preparation-cleanup.js +106 -0
  55. package/dist/server/publication.d.ts +78 -0
  56. package/dist/server/publication.js +263 -31
  57. package/dist/server/service.d.ts +60 -0
  58. package/dist/server/service.js +181 -0
  59. package/dist/testing.d.ts +50 -0
  60. package/dist/testing.js +86 -0
  61. package/docs/migration-l6.md +134 -0
  62. package/examples/radar-commit-setup.ts +302 -0
  63. package/l0-freeze/command-identity-contract.ts +303 -0
  64. package/l0-freeze/commit-contract.ts +421 -0
  65. package/l0-freeze/failure-contract.ts +174 -0
  66. package/l0-freeze/profile-contract.ts +219 -0
  67. package/migrations/0003_coordinator.sql +28 -0
  68. package/package.json +27 -1
  69. package/src/contracts.ts +125 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * L3 (immo #861) — typed Radar construction example (conductor-directed).
3
+ *
4
+ * Shows the exact construction Radar wires: a real coordinator over the
5
+ * PostgreSQL executor, a transaction-bound PG policy/quota participant
6
+ * registered at construction, fence minting in the host guard, and the
7
+ * preparation-abandonment queue bound to a cleanup sweeper. Imports only
8
+ * public package entrypoints. Pass this coordinator plus the compiled profile to
9
+ * `createHttpArtifactApp` (r2 section 3.2); nothing here invents that seam.
10
+ * The `radar_commit_example` tables below are illustrative host tables so
11
+ * the example runs standalone; Radar's real adapter owns its own tables.
12
+ */
13
+ import type { Pool } from 'pg';
14
+ import { createPostgresCommitExecutor } from '@sentropic/artifacts/adapters/postgres-commit';
15
+ import { createArtifactCommitCoordinator } from '@sentropic/artifacts/server/commit-contract';
16
+ import { compileArtifactsProfile, RADAR_ARTIFACTS_PROFILE } from '@sentropic/artifacts/mcp-core';
17
+ import { createPostgresArtifactStore } from '@sentropic/artifacts/adapters/postgres';
18
+ import { createPostgresDeletionClaimStore } from '@sentropic/artifacts/server/gc';
19
+ import { scheduleBoundedPreparationCleanup } from '@sentropic/artifacts/server/object-intents';
20
+ import type { PublicationBlobStore } from '@sentropic/artifacts/server/publication';
21
+ import type {
22
+ ArtifactAuthorizationDecision,
23
+ ArtifactCommitContext,
24
+ ArtifactCommitTransaction,
25
+ ArtifactPolicyDriver,
26
+ CommitPolicyParticipantFactory,
27
+ CommitCoordinatorOptions,
28
+ CoordinatorTestPoint,
29
+ ArtifactCommitCoordinator,
30
+ FinalizeRequest,
31
+ FinalizeResult,
32
+ InternalIdempotencyKey,
33
+ } from '@sentropic/artifacts/server/commit-contract';
34
+
35
+ /** One abandoned preparation awaiting host cleanup. */
36
+ export interface AbandonedAttempt {
37
+ readonly attemptId: string;
38
+ readonly scopeId: string;
39
+ readonly internalKey: string;
40
+ readonly atMs: number;
41
+ }
42
+
43
+ /** Configure publication with this lifetime; sweep cadence is part of the activation bound. */
44
+ export const RADAR_PREPARATION_LIFETIME_MS = 30_000;
45
+
46
+ /** Bind durable intent cleanup before activation; observe failures as preparation debt. */
47
+ export function startRadarPreparationCleanup(options: {
48
+ pool: Pool;
49
+ blobs: PublicationBlobStore;
50
+ onError(error: unknown): void;
51
+ intervalMs?: number;
52
+ }): { runOnce(): Promise<void>; stop(): void } {
53
+ const intervalMs = options.intervalMs ?? 1000;
54
+ if (!Number.isSafeInteger(intervalMs) || intervalMs < 1 || intervalMs > RADAR_PREPARATION_LIFETIME_MS) {
55
+ throw new Error('invalid preparation cleanup interval');
56
+ }
57
+ const executor = createPostgresCommitExecutor(options.pool);
58
+ const store = createPostgresArtifactStore(options.pool);
59
+ let running: Promise<void> | undefined;
60
+ const runOnce = (): Promise<void> => {
61
+ if (running !== undefined) return running;
62
+ running = (async () => {
63
+ const client = await executor.beginFinalization();
64
+ let failure: unknown;
65
+ try {
66
+ await client.beginReadCommitted();
67
+ await scheduleBoundedPreparationCleanup({ intents: store, refs: store,
68
+ claims: createPostgresDeletionClaimStore(client), blobs: options.blobs }, Date.now());
69
+ await client.commit();
70
+ } catch (error) {
71
+ failure = error;
72
+ try { await client.rollback(); } catch (rollbackError) { failure = rollbackError; }
73
+ throw error;
74
+ } finally { client.release(failure); }
75
+ })().finally(() => { running = undefined; });
76
+ return running;
77
+ };
78
+ const timer = setInterval(() => { void runOnce().catch(options.onError); }, intervalMs);
79
+ return { runOnce, stop: () => clearInterval(timer) };
80
+ }
81
+
82
+ /** Create the illustrative host tables and seed one approved actor plus quota. */
83
+ export async function prepareRadarCommitExample(pool: Pool): Promise<void> {
84
+ await pool.query('CREATE SCHEMA IF NOT EXISTS radar_commit_example');
85
+ await pool.query(
86
+ `CREATE TABLE IF NOT EXISTS radar_commit_example.policy_revision
87
+ (id INT PRIMARY KEY CHECK (id = 1), revision TEXT NOT NULL)`,
88
+ );
89
+ await pool.query(
90
+ `INSERT INTO radar_commit_example.policy_revision (id, revision)
91
+ VALUES (1, 'rev-1') ON CONFLICT (id) DO UPDATE SET revision = 'rev-1'`,
92
+ );
93
+ await pool.query(
94
+ `CREATE TABLE IF NOT EXISTS radar_commit_example.approvals
95
+ (actor_id TEXT PRIMARY KEY, approved BOOLEAN NOT NULL, expires_at_ms BIGINT NOT NULL)`,
96
+ );
97
+ await pool.query(
98
+ `INSERT INTO radar_commit_example.approvals (actor_id, approved, expires_at_ms)
99
+ VALUES ('radar-actor', true, 9999999999999)
100
+ ON CONFLICT (actor_id) DO UPDATE SET approved = true, expires_at_ms = 9999999999999`,
101
+ );
102
+ await pool.query(
103
+ `CREATE TABLE IF NOT EXISTS radar_commit_example.quotas
104
+ (quota_key TEXT PRIMARY KEY, total INT NOT NULL, used INT NOT NULL)`,
105
+ );
106
+ await pool.query(
107
+ `INSERT INTO radar_commit_example.quotas (quota_key, total, used)
108
+ VALUES ('radar-quota', 100, 0) ON CONFLICT (quota_key) DO UPDATE SET used = 0`,
109
+ );
110
+ }
111
+
112
+ /** The host guard Radar wires: current revision, approval and fence minting. */
113
+ export async function allowRadarGuard(
114
+ context: ArtifactCommitContext,
115
+ transaction: ArtifactCommitTransaction,
116
+ ): Promise<ArtifactAuthorizationDecision> {
117
+ const revision = await transaction.participant.readRevision();
118
+ if (context.policyRevision !== revision) {
119
+ return {
120
+ allowed: false, reason: 'revoked', publicCode: 'access_denied', status: 403, retriable: false,
121
+ };
122
+ }
123
+ const check = await transaction.participant.checkApproval({
124
+ actorId: context.actorId,
125
+ operation: context.operation,
126
+ });
127
+ if (!check.approved) {
128
+ return {
129
+ allowed: false, reason: check.reason ?? 'denied',
130
+ publicCode: 'access_denied', status: 403, retriable: false,
131
+ };
132
+ }
133
+ return { allowed: true, fence: transaction.participant.mintFence(revision, context.invocationId) };
134
+ }
135
+
136
+ const createParticipantFactory = (
137
+ quotaKey: string,
138
+ clock: { nowMs: number },
139
+ ): CommitPolicyParticipantFactory => ({
140
+ createParticipant: (client, transactionToken): ArtifactPolicyDriver => ({
141
+ async readRevision(): Promise<string> {
142
+ const { rows } = await client.query<{ revision: string }>(
143
+ 'SELECT revision FROM radar_commit_example.policy_revision WHERE id = 1 FOR SHARE',
144
+ [],
145
+ );
146
+ const row = rows[0];
147
+ if (row === undefined) throw new Error('missing policy revision');
148
+ return row.revision;
149
+ },
150
+ async checkApproval(input: { actorId: string }) {
151
+ const { rows } = await client.query<{ approved: boolean; expires_at_ms: string }>(
152
+ 'SELECT approved, expires_at_ms FROM radar_commit_example.approvals WHERE actor_id = $1 FOR SHARE',
153
+ [input.actorId],
154
+ );
155
+ const row = rows[0];
156
+ if (row === undefined || !row.approved) return { approved: false as const, reason: 'denied' as const };
157
+ if (Number(row.expires_at_ms) <= clock.nowMs) {
158
+ return { approved: false as const, reason: 'expired' as const };
159
+ }
160
+ return { approved: true as const };
161
+ },
162
+ async reserveQuota(): Promise<{ reserved: boolean }> {
163
+ const { rowCount } = await client.query(
164
+ 'UPDATE radar_commit_example.quotas SET used = used + 1 WHERE quota_key = $1 AND used < total',
165
+ [quotaKey],
166
+ );
167
+ return { reserved: rowCount > 0 };
168
+ },
169
+ mintFence: (revision: string, invocationId: string) => ({
170
+ fenceId: `fence-${transactionToken}`,
171
+ transactionId: transactionToken,
172
+ invocationId,
173
+ policyRevision: revision,
174
+ }),
175
+ }),
176
+ });
177
+
178
+ export interface RadarCommitInput {
179
+ readonly scopeId: string;
180
+ readonly internalKey: string;
181
+ readonly commandHash: string;
182
+ readonly invocationId: string;
183
+ readonly operation: string;
184
+ readonly receiptData: unknown;
185
+ }
186
+
187
+ export interface RadarCommitSetup {
188
+ readonly coordinator: ArtifactCommitCoordinator;
189
+ readonly profile: ReturnType<typeof compileArtifactsProfile>;
190
+ readonly abandonedAttempts: AbandonedAttempt[];
191
+ finalizePrepared(input: RadarCommitInput): Promise<FinalizeResult>;
192
+ replayPrepared(input: RadarCommitInput): Promise<FinalizeResult>;
193
+ drainAbandonedAttempts(onSweep: (batch: AbandonedAttempt[]) => void): void;
194
+ startAbandonedAttemptSweep(
195
+ onSweep: (batch: AbandonedAttempt[]) => void | Promise<void>,
196
+ intervalMs: number,
197
+ ): () => void;
198
+ }
199
+
200
+ /**
201
+ * Build the exact Radar construction over one pool. The optional
202
+ * `testObserver` forwards barrier observations to the coordinator;
203
+ * `testPoints` optionally filters them (see `testing.ts`).
204
+ */
205
+ export function createRadarCommitSetup(options: {
206
+ pool: Pool;
207
+ clock: { nowMs: number };
208
+ quotaKey?: string;
209
+ /** Bind this same mapper on createHttpArtifactApp and createArtifactsAdapter. */
210
+ mapHostError?: CommitCoordinatorOptions['mapHostError'];
211
+ testObserver?: CommitCoordinatorOptions['testObserver'];
212
+ testPoints?: readonly CoordinatorTestPoint[];
213
+ }): RadarCommitSetup {
214
+ const quotaKey = options.quotaKey ?? 'radar-quota';
215
+ const abandonedAttempts: AbandonedAttempt[] = [];
216
+ const coordinator = createArtifactCommitCoordinator({
217
+ mapHostError: options.mapHostError,
218
+ executor: createPostgresCommitExecutor(options.pool),
219
+ policy: createParticipantFactory(quotaKey, options.clock),
220
+ nowMs: () => options.clock.nowMs,
221
+ testObserver: options.testObserver,
222
+ testPoints: options.testPoints,
223
+ });
224
+ const toRequest = (
225
+ input: RadarCommitInput,
226
+ ): FinalizeRequest & { attemptId: string } => ({
227
+ scopeId: input.scopeId,
228
+ internalKey: input.internalKey as InternalIdempotencyKey,
229
+ commandHash: input.commandHash,
230
+ codecVersion: 'artifacts-command/v1',
231
+ invocationId: input.invocationId,
232
+ actorId: 'radar-actor',
233
+ operation: input.operation,
234
+ artifactId: null,
235
+ bindingName: null,
236
+ policyRevision: 'rev-1',
237
+ guard: allowRadarGuard,
238
+ receiptData: input.receiptData,
239
+ attemptId: input.invocationId,
240
+ noteAbandonedAttempt: async (attemptId: string) => {
241
+ abandonedAttempts.push({
242
+ attemptId,
243
+ scopeId: input.scopeId,
244
+ internalKey: input.internalKey,
245
+ atMs: options.clock.nowMs,
246
+ });
247
+ },
248
+ });
249
+ const drainAbandonedAttempts = (onSweep: (batch: AbandonedAttempt[]) => void): void => {
250
+ if (abandonedAttempts.length === 0) return;
251
+ // Retain-then-remove: a throwing sweeper keeps the batch for the next
252
+ // sweep instead of losing it. Durable cleanup state, per-attempt
253
+ // deadlines and restart recovery stay L4-owned (intent metadata).
254
+ const batch = abandonedAttempts.slice();
255
+ onSweep(batch);
256
+ abandonedAttempts.splice(0, batch.length);
257
+ };
258
+ return {
259
+ coordinator,
260
+ profile: compileArtifactsProfile(RADAR_ARTIFACTS_PROFILE),
261
+ abandonedAttempts,
262
+ finalizePrepared: (input: RadarCommitInput) => coordinator.finalizePrepared(toRequest(input)),
263
+ replayPrepared: (input: RadarCommitInput) => coordinator.replayPrepared(toRequest(input)),
264
+ drainAbandonedAttempts,
265
+ startAbandonedAttemptSweep: (onSweep, intervalMs) => {
266
+ // Serialized sweeps: an overlapping tick queues behind the running
267
+ // sweep instead of sweeping concurrently, and only the swept entries
268
+ // are removed, so arrivals landing mid-sweep are never deleted.
269
+ let sweeping = false;
270
+ let queued = false;
271
+ const runSweep = (): void => {
272
+ if (sweeping) {
273
+ queued = true;
274
+ return;
275
+ }
276
+ sweeping = true;
277
+ void (async () => {
278
+ try {
279
+ do {
280
+ queued = false;
281
+ const batch = abandonedAttempts.slice();
282
+ try {
283
+ await onSweep(batch);
284
+ } catch {
285
+ /* retained for the next sweep */
286
+ break;
287
+ }
288
+ for (const entry of batch) {
289
+ const index = abandonedAttempts.indexOf(entry);
290
+ if (index !== -1) abandonedAttempts.splice(index, 1);
291
+ }
292
+ } while (queued);
293
+ } finally {
294
+ sweeping = false;
295
+ }
296
+ })();
297
+ };
298
+ const timer = setInterval(runSweep, intervalMs);
299
+ return () => clearInterval(timer);
300
+ },
301
+ };
302
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * L0 frozen command-identity declaration (immo #861, ask 1 shape, spec r2 C5-C7).
3
+ *
4
+ * Stable data shapes for the effective command and its server-created
5
+ * identity, plus the frozen derivation fixtures: preimage construction,
6
+ * expected digests and ambiguous-split refusal. The command codec
7
+ * implementation lands in L1/L6 against the declared relations; domain
8
+ * qualification of trusted scope/subject mappings stays gated on Q0.
9
+ */
10
+
11
+ /** Version of the frozen command-codec shape. */
12
+ export const COMMAND_CODEC_VERSION = 'artifacts-command/v1' as const;
13
+
14
+ /** Caller-supplied key: opaque to the server, never trusted as identity. */
15
+ export type CallerIdempotencyKey = string & { readonly __brand: 'caller-key' };
16
+
17
+ /** Server-derived internal key: distinct from any caller key. */
18
+ export type InternalIdempotencyKey = string & { readonly __brand: 'internal-key' };
19
+
20
+ /**
21
+ * Trusted host derivation binding. The algorithm names Radar's existing
22
+ * SHA-256 construction over the UTF-8 encoding of the five components
23
+ * joined with `|`, in scope/actor/operation/objectId/callerKey order.
24
+ * An ambiguous concatenation MUST fail host configuration or use an
25
+ * explicitly versioned migration; existing durable keys are never
26
+ * silently re-derived.
27
+ */
28
+ export interface IdempotencyDerivationBinding {
29
+ readonly algorithm: 'radar-derive-idempotency-v1';
30
+ readonly scope: string;
31
+ readonly actor: string;
32
+ readonly operation: string;
33
+ readonly objectId: string;
34
+ readonly callerKey: string;
35
+ }
36
+
37
+ /** Join the five binding components with `|`; the hash covers this preimage. */
38
+ export function buildDerivationPreimage(binding: IdempotencyDerivationBinding): string {
39
+ return [
40
+ binding.scope, binding.actor, binding.operation,
41
+ binding.objectId, binding.callerKey,
42
+ ].join('|');
43
+ }
44
+
45
+ /** One expected derivation fixture with its precomputed SHA-256 digest. */
46
+ export interface DerivationVector {
47
+ readonly name: string;
48
+ readonly binding: IdempotencyDerivationBinding;
49
+ readonly preimage: string;
50
+ readonly sha256: string;
51
+ }
52
+
53
+ export const DERIVATION_VECTORS: readonly DerivationVector[] = [
54
+ {
55
+ name: 'publish-basic',
56
+ binding: {
57
+ algorithm: 'radar-derive-idempotency-v1', scope: 's', actor: 'alice',
58
+ operation: 'publish', objectId: 'obj-1', callerKey: 'key-1',
59
+ },
60
+ preimage: 's|alice|publish|obj-1|key-1',
61
+ sha256: '4a7c17265a6ee4720ff3e941bc42f9c3f2f55b64094076b591d5484d698c7d6a',
62
+ },
63
+ {
64
+ name: 'ambiguous-split-preimage',
65
+ binding: {
66
+ algorithm: 'radar-derive-idempotency-v1', scope: 's|issuer', actor: 'alice',
67
+ operation: 'publish', objectId: 'obj-1', callerKey: 'key-1',
68
+ },
69
+ preimage: 's|issuer|alice|publish|obj-1|key-1',
70
+ sha256: '535a79665cbecdcd2c20621d05791568f3f57bacc76b5f059b91f6d0ceda4bb2',
71
+ },
72
+ {
73
+ name: 'ensure-empty-object',
74
+ binding: {
75
+ algorithm: 'radar-derive-idempotency-v1', scope: 'tenant:t1', actor: 'alice',
76
+ operation: 'ensure', objectId: '', callerKey: 'key-2',
77
+ },
78
+ preimage: 'tenant:t1|alice|ensure||key-2',
79
+ sha256: '437be4b21dce49e880ba3cc697fef667bed4ed0c0ec90e9cbdbad43f1a4a5cbb',
80
+ },
81
+ {
82
+ name: 'ensure-alice',
83
+ binding: {
84
+ algorithm: 'radar-derive-idempotency-v1', scope: 's', actor: 'alice',
85
+ operation: 'ensure', objectId: '', callerKey: 'key-2',
86
+ },
87
+ preimage: 's|alice|ensure||key-2',
88
+ sha256: '68934203b875d8ea78a6533b2a971f86b49c1c32416da4af8bd786058a49e1fa',
89
+ },
90
+ {
91
+ name: 'ensure-issuer-alice',
92
+ binding: {
93
+ algorithm: 'radar-derive-idempotency-v1', scope: 's', actor: 'issuer|alice',
94
+ operation: 'ensure', objectId: '', callerKey: 'key-2',
95
+ },
96
+ preimage: 's|issuer|alice|ensure||key-2',
97
+ sha256: '1d9b9a3f67238ad0cc6a7ac257232c2d0a1e5dc709d58d09901a2ca68fb98b28',
98
+ },
99
+ ];
100
+
101
+ /**
102
+ * True when two bindings share one preimage through different component
103
+ * splits. Such an ambiguous concatenation is refused: the distinct
104
+ * scope/actor pairs never share a durable identity without an explicitly
105
+ * qualified canonical mapping or a versioned migration.
106
+ */
107
+ export function detectAmbiguousSplit(
108
+ first: IdempotencyDerivationBinding,
109
+ second: IdempotencyDerivationBinding,
110
+ ): boolean {
111
+ if (buildDerivationPreimage(first) !== buildDerivationPreimage(second)) return false;
112
+ return first.scope !== second.scope
113
+ || first.actor !== second.actor
114
+ || first.operation !== second.operation
115
+ || first.objectId !== second.objectId
116
+ || first.callerKey !== second.callerKey;
117
+ }
118
+
119
+ /**
120
+ * C7 negative fixture: `('s|issuer', 'alice')` and `('s', 'issuer|alice')`
121
+ * produce the same legacy preimage with identical remaining components,
122
+ * so the host MUST refuse (or versioned-migrate) before acceptance. HTTP
123
+ * `alice` versus MCP `issuer|alice` without a qualified canonical mapping
124
+ * are likewise distinct principals and MUST NOT replay each other's receipt.
125
+ */
126
+ export const AMBIGUOUS_DERIVATION_CASE = {
127
+ first: {
128
+ algorithm: 'radar-derive-idempotency-v1', scope: 's|issuer', actor: 'alice',
129
+ operation: 'publish', objectId: 'obj-1', callerKey: 'key-1',
130
+ },
131
+ second: {
132
+ algorithm: 'radar-derive-idempotency-v1', scope: 's', actor: 'issuer|alice',
133
+ operation: 'publish', objectId: 'obj-1', callerKey: 'key-1',
134
+ },
135
+ expected: 'refuse',
136
+ } as const;
137
+
138
+ /**
139
+ * Platform envelope rule (C7): the optional envelope key is never an
140
+ * alternative source. `accept` proceeds, `conflict` is a 400 on a
141
+ * mismatching envelope, `envelope-only` is a 400 on an envelope without
142
+ * a tool argument. It adds no second platform idempotency store.
143
+ */
144
+ export function resolvePlatformEnvelope(
145
+ argumentKey: string | undefined,
146
+ envelopeKey: string | undefined,
147
+ ): 'accept' | 'conflict' | 'envelope-only' {
148
+ if (argumentKey === undefined) return 'envelope-only';
149
+ if (envelopeKey === undefined || envelopeKey === argumentKey) return 'accept';
150
+ return 'conflict';
151
+ }
152
+
153
+ /**
154
+ * Command-identity relations the L1/L6 codec MUST enforce: equivalent
155
+ * text/byte inputs and object-property order converge, while changed
156
+ * bytes, path, MIME, profile, provenance or file-array order conflict
157
+ * under the same internal key. A hash supplied in a public request never
158
+ * overrides the computed hash.
159
+ */
160
+ export const COMMAND_IDENTITY_RELATIONS = [
161
+ {
162
+ name: 'text-bytes-equivalence',
163
+ description: 'Equivalent text and byte file inputs converge to one command hash.',
164
+ expectation: 'converge',
165
+ },
166
+ {
167
+ name: 'file-array-order',
168
+ description: 'Changed file-array order conflicts under the same internal key.',
169
+ expectation: 'conflict',
170
+ },
171
+ {
172
+ name: 'request-hash-ignored',
173
+ description: 'A hash supplied in a public request never overrides the computed hash.',
174
+ expectation: 'computed-only',
175
+ },
176
+ ] as const;
177
+
178
+ /**
179
+ * Deterministic JSON canonicalization for effective command bodies:
180
+ * object keys sort recursively in UTF-16 code-unit order, array order
181
+ * stays significant, and the encoder never mutates its input. L1/L6 hash
182
+ * the canonical bytes (SHA-256) after trusted scope binding, defaults,
183
+ * UTF-8/byte conversion and forced provenance; text/byte equivalence is
184
+ * established on the decoded form before this encoding, and the codec
185
+ * deep-copies before normalization.
186
+ */
187
+ export function canonicalizeJson(value: unknown): string {
188
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
189
+ if (Array.isArray(value)) {
190
+ return `[${value.map((entry) => canonicalizeJson(entry)).join(',')}]`;
191
+ }
192
+ const record = value as Record<string, unknown>;
193
+ const keys = Object.keys(record).sort();
194
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalizeJson(record[key])}`).join(',')}}`;
195
+ }
196
+
197
+ /** One golden effective-command fixture with its canonical bytes and digest. */
198
+ export interface CanonicalCommandFixture {
199
+ readonly name: string;
200
+ readonly body: {
201
+ readonly operation: string;
202
+ readonly scopeBinding: string;
203
+ readonly payload: Record<string, unknown>;
204
+ };
205
+ readonly canonical: string;
206
+ readonly sha256: string;
207
+ }
208
+
209
+ export const CANONICAL_COMMAND_FIXTURES: readonly CanonicalCommandFixture[] = [
210
+ {
211
+ name: 'publish-baseline',
212
+ body: {
213
+ operation: 'publish',
214
+ scopeBinding: 's',
215
+ payload: {
216
+ artifactId: 'obj-1',
217
+ files: [
218
+ { bytes: [60, 104, 49, 62, 72, 105, 60, 47, 104, 49, 62], mimeType: 'text/html', path: 'index.html' },
219
+ { bytes: [98, 111, 100, 121], mimeType: 'text/css', path: 'styles.css' },
220
+ ],
221
+ profile: {},
222
+ provenance: { kind: 'inline-original' },
223
+ },
224
+ },
225
+ canonical: '{"operation":"publish","payload":{"artifactId":"obj-1","files":[{"bytes":[60,104,49,62,72,105,60,47,104,49,62],"mimeType":"text/html","path":"index.html"},{"bytes":[98,111,100,121],"mimeType":"text/css","path":"styles.css"}],"profile":{},"provenance":{"kind":"inline-original"}},"scopeBinding":"s"}',
226
+ sha256: 'c1c8a186b847635ae4b1d1c27bf1079ae1cf26c1ef4b47ed622cafaa6a273d57',
227
+ },
228
+ {
229
+ name: 'publish-shuffled-keys',
230
+ body: {
231
+ payload: {
232
+ provenance: { kind: 'inline-original' },
233
+ profile: {},
234
+ files: [
235
+ { path: 'index.html', mimeType: 'text/html', bytes: [60, 104, 49, 62, 72, 105, 60, 47, 104, 49, 62] },
236
+ { path: 'styles.css', mimeType: 'text/css', bytes: [98, 111, 100, 121] },
237
+ ],
238
+ artifactId: 'obj-1',
239
+ },
240
+ scopeBinding: 's',
241
+ operation: 'publish',
242
+ },
243
+ canonical: '{"operation":"publish","payload":{"artifactId":"obj-1","files":[{"bytes":[60,104,49,62,72,105,60,47,104,49,62],"mimeType":"text/html","path":"index.html"},{"bytes":[98,111,100,121],"mimeType":"text/css","path":"styles.css"}],"profile":{},"provenance":{"kind":"inline-original"}},"scopeBinding":"s"}',
244
+ sha256: 'c1c8a186b847635ae4b1d1c27bf1079ae1cf26c1ef4b47ed622cafaa6a273d57',
245
+ },
246
+ {
247
+ name: 'publish-swapped-file-order',
248
+ body: {
249
+ operation: 'publish',
250
+ scopeBinding: 's',
251
+ payload: {
252
+ artifactId: 'obj-1',
253
+ files: [
254
+ { bytes: [98, 111, 100, 121], mimeType: 'text/css', path: 'styles.css' },
255
+ { bytes: [60, 104, 49, 62, 72, 105, 60, 47, 104, 49, 62], mimeType: 'text/html', path: 'index.html' },
256
+ ],
257
+ profile: {},
258
+ provenance: { kind: 'inline-original' },
259
+ },
260
+ },
261
+ canonical: '{"operation":"publish","payload":{"artifactId":"obj-1","files":[{"bytes":[98,111,100,121],"mimeType":"text/css","path":"styles.css"},{"bytes":[60,104,49,62,72,105,60,47,104,49,62],"mimeType":"text/html","path":"index.html"}],"profile":{},"provenance":{"kind":"inline-original"}},"scopeBinding":"s"}',
262
+ sha256: 'ebf2c9342f120f4aeea857656cce1ff91a359d16eb493af97878350bc7b0f990',
263
+ },
264
+ {
265
+ name: 'publish-changed-mime',
266
+ body: {
267
+ operation: 'publish',
268
+ scopeBinding: 's',
269
+ payload: {
270
+ artifactId: 'obj-1',
271
+ files: [
272
+ { bytes: [60, 104, 49, 62, 72, 105, 60, 47, 104, 49, 62], mimeType: 'text/html', path: 'index.html' },
273
+ { bytes: [98, 111, 100, 121], mimeType: 'text/javascript', path: 'styles.css' },
274
+ ],
275
+ profile: {},
276
+ provenance: { kind: 'inline-original' },
277
+ },
278
+ },
279
+ canonical: '{"operation":"publish","payload":{"artifactId":"obj-1","files":[{"bytes":[60,104,49,62,72,105,60,47,104,49,62],"mimeType":"text/html","path":"index.html"},{"bytes":[98,111,100,121],"mimeType":"text/javascript","path":"styles.css"}],"profile":{},"provenance":{"kind":"inline-original"}},"scopeBinding":"s"}',
280
+ sha256: '90d3680386f461353f16f2c004271b1298a67895b3249eec1566320a52990528',
281
+ },
282
+ ];
283
+
284
+ /** Server-created identity for one durable command. */
285
+ export interface CommandIdentity {
286
+ readonly internalKey: InternalIdempotencyKey;
287
+ readonly commandHash: string;
288
+ readonly codecVersion: typeof COMMAND_CODEC_VERSION;
289
+ }
290
+
291
+ /**
292
+ * Effective command after trusted scope binding, defaults, UTF-8/byte
293
+ * conversion and forced provenance. Object keys sort recursively; array
294
+ * order remains significant. A hash supplied in a public request never
295
+ * overrides the computed hash.
296
+ */
297
+ export interface EffectiveArtifactCommand {
298
+ readonly operation: string;
299
+ readonly scopeBinding: string;
300
+ readonly payload: Record<string, unknown>;
301
+ readonly provenance: { readonly kind: 'inline-original' };
302
+ readonly identity: CommandIdentity;
303
+ }