@sentropic/artifacts 0.2.5 → 0.2.6

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 (43) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +13 -3
  3. package/dist/adapters/postgres.js +11 -4
  4. package/dist/http.d.ts +3 -2
  5. package/dist/http.js +221 -36
  6. package/dist/mcp-core.d.ts +3 -3
  7. package/dist/mcp-core.js +19 -9
  8. package/dist/profile/contracts.d.ts +15 -5
  9. package/dist/profile/contracts.js +9 -6
  10. package/dist/profile/index.js +33 -4
  11. package/dist/profile/radar.js +4 -1
  12. package/dist/profile/schemas.js +31 -5
  13. package/dist/profile/validation.d.ts +3 -1
  14. package/dist/profile/validation.js +32 -13
  15. package/dist/server/access.d.ts +8 -0
  16. package/dist/server/command-identity.d.ts +4 -2
  17. package/dist/server/command-identity.js +5 -5
  18. package/dist/server/commit/coordinator.js +12 -9
  19. package/dist/server/commit/types.d.ts +4 -0
  20. package/dist/server/edits.d.ts +17 -0
  21. package/dist/server/edits.js +43 -0
  22. package/dist/server/failures.d.ts +31 -2
  23. package/dist/server/failures.js +14 -3
  24. package/dist/server/lifecycle.d.ts +2 -0
  25. package/dist/server/lifecycle.js +3 -0
  26. package/dist/server/management.d.ts +1 -0
  27. package/dist/server/management.js +9 -2
  28. package/dist/server/outcome-projection.d.ts +2 -2
  29. package/dist/server/outcome-projection.js +25 -9
  30. package/dist/server/publication.d.ts +18 -1
  31. package/dist/server/publication.js +54 -5
  32. package/dist/server/result-schema.d.ts +14 -0
  33. package/dist/server/result-schema.js +35 -0
  34. package/dist/server/service.d.ts +2 -0
  35. package/dist/server/service.js +2 -0
  36. package/dist/server/summary.d.ts +4 -3
  37. package/dist/server/summary.js +4 -3
  38. package/dist/server/templates.js +24 -3
  39. package/dist/server/versions.d.ts +29 -0
  40. package/dist/server/versions.js +39 -0
  41. package/docs/migration-l6.md +8 -1
  42. package/migrations/0005_metadata.sql +14 -0
  43. package/package.json +2 -2
@@ -1,7 +1,7 @@
1
1
  import type { JsonValue } from '../contracts.js';
2
2
  import type { EffectiveArtifactCommand } from '../server/command-identity.js';
3
3
  export declare const PROFILE_CONTRACT_VERSION: "artifacts-profile/v1";
4
- export declare const ARTIFACT_CAPABILITY_NAMES: readonly ["artifacts_get_template", "artifacts_get_api_guide", "artifacts_ensure", "artifacts_publish", "artifacts_create_upload", "artifacts_commit_upload", "artifacts_list", "artifacts_get", "artifacts_get_source", "artifacts_fork", "artifacts_set_access", "artifacts_promote", "artifacts_renew", "artifacts_delete", "artifacts_restore"];
4
+ export declare const ARTIFACT_CAPABILITY_NAMES: readonly ["artifacts_get_template", "artifacts_get_api_guide", "artifacts_ensure", "artifacts_publish", "artifacts_create_upload", "artifacts_commit_upload", "artifacts_list", "artifacts_get", "artifacts_get_source", "artifacts_fork", "artifacts_set_access", "artifacts_promote", "artifacts_renew", "artifacts_delete", "artifacts_restore", "artifacts_list_versions", "artifacts_revert"];
5
5
  export type ArtifactCapabilityName = (typeof ARTIFACT_CAPABILITY_NAMES)[number];
6
6
  export type ArtifactSchema = Readonly<Record<string, unknown>>;
7
7
  export interface OperationProfile {
@@ -22,6 +22,12 @@ export interface ArtifactProfileRules {
22
22
  readonly mimeTypes: readonly string[];
23
23
  }
24
24
  export interface ArtifactProviderProfile {
25
+ /** Trusted public contract revision; independent from the host policy revision. */
26
+ readonly profileRevisionId?: string;
27
+ /** Explicit projection switchover; omitted profiles retain the v1 wire shape. */
28
+ readonly resultEnvelopeVersion?: 1 | 2;
29
+ /** Require caller-supplied CAS preconditions on every publication writer. */
30
+ readonly requireExpectedCurrentReleaseId?: boolean;
25
31
  readonly version: typeof PROFILE_CONTRACT_VERSION;
26
32
  readonly selectedCapabilities: readonly string[];
27
33
  readonly operations: readonly OperationProfile[];
@@ -42,7 +48,9 @@ export interface CompiledArtifactsProfile extends EffectiveArtifactProfile {
42
48
  readonly required: true;
43
49
  readonly scope: 'principal';
44
50
  }>>>;
45
- validate(name: string, args: unknown, envelopeKey?: string): Readonly<Record<string, JsonValue>>;
51
+ validate(name: string, args: unknown, envelopeKey?: string, options?: {
52
+ deferPrecondition: true;
53
+ }): Readonly<Record<string, JsonValue>>;
46
54
  assertSelected(name: string): asserts name is ArtifactCapabilityName;
47
55
  capabilityForRoute(method: string, path: string): ArtifactCapabilityName | undefined;
48
56
  }
@@ -53,15 +61,17 @@ export interface CompiledValidators {
53
61
  readonly normalizeCommand: (command: unknown) => EffectiveArtifactCommand;
54
62
  }
55
63
  /** Projection fields are data-only; invocation correlation is added by L6. */
64
+ export declare const PRECONDITION_REQUIRED_MESSAGE = "expectedCurrentReleaseId is required by the active artifact profile. Re-read this artifact with artifacts_get or artifacts_list, then send expectedCurrentReleaseId using the currentReleaseId returned by that read. Send null only if the read confirms that there is no current release. Before editing an existing release, read it with artifacts_get_source and use that releaseId. The server will not infer this value.";
56
65
  export declare class ArtifactProfileInputError extends Error {
66
+ readonly details?: Record<string, unknown> | undefined;
57
67
  readonly kind: 'operational' | 'ordinary';
58
68
  readonly status: number;
59
69
  readonly httpStatus: number;
60
- readonly variant: 'unselected_operation' | 'invalid_request';
70
+ readonly variant: 'unselected_operation' | 'invalid_request' | 'precondition_required';
61
71
  readonly publicCode: string;
62
- readonly category: 'invalid_request' | 'capability_unselected';
72
+ readonly category: 'invalid_request' | 'capability_unselected' | 'precondition_required';
63
73
  readonly retriable = false;
64
74
  readonly phase: 'selection' | 'entry';
65
75
  readonly commitState = "not_committed";
66
- constructor(unselected?: boolean);
76
+ constructor(unselected?: boolean, preconditionRequired?: boolean, details?: Record<string, unknown> | undefined);
67
77
  }
@@ -4,10 +4,12 @@ export const ARTIFACT_CAPABILITY_NAMES = [
4
4
  'artifacts_publish', 'artifacts_create_upload', 'artifacts_commit_upload',
5
5
  'artifacts_list', 'artifacts_get', 'artifacts_get_source', 'artifacts_fork',
6
6
  'artifacts_set_access', 'artifacts_promote', 'artifacts_renew',
7
- 'artifacts_delete', 'artifacts_restore',
7
+ 'artifacts_delete', 'artifacts_restore', 'artifacts_list_versions', 'artifacts_revert',
8
8
  ];
9
9
  /** Projection fields are data-only; invocation correlation is added by L6. */
10
+ export const PRECONDITION_REQUIRED_MESSAGE = 'expectedCurrentReleaseId is required by the active artifact profile. Re-read this artifact with artifacts_get or artifacts_list, then send expectedCurrentReleaseId using the currentReleaseId returned by that read. Send null only if the read confirms that there is no current release. Before editing an existing release, read it with artifacts_get_source and use that releaseId. The server will not infer this value.';
10
11
  export class ArtifactProfileInputError extends Error {
12
+ details;
11
13
  kind;
12
14
  status;
13
15
  httpStatus;
@@ -17,15 +19,16 @@ export class ArtifactProfileInputError extends Error {
17
19
  retriable = false;
18
20
  phase;
19
21
  commitState = 'not_committed';
20
- constructor(unselected = false) {
21
- super(unselected ? 'capability_unavailable' : 'invalid_request');
22
+ constructor(unselected = false, preconditionRequired = false, details) {
23
+ super(unselected ? 'capability_unavailable' : preconditionRequired ? 'precondition_required' : 'invalid_request');
24
+ this.details = details;
22
25
  this.name = 'ArtifactProfileInputError';
23
26
  this.kind = unselected ? 'operational' : 'ordinary';
24
- this.status = unselected ? 503 : 400;
27
+ this.status = unselected ? 503 : preconditionRequired ? 428 : 400;
25
28
  this.httpStatus = this.status;
26
- this.variant = unselected ? 'unselected_operation' : 'invalid_request';
29
+ this.variant = unselected ? 'unselected_operation' : preconditionRequired ? 'precondition_required' : 'invalid_request';
27
30
  this.publicCode = this.message;
28
- this.category = unselected ? 'capability_unselected' : 'invalid_request';
31
+ this.category = unselected ? 'capability_unselected' : preconditionRequired ? 'precondition_required' : 'invalid_request';
29
32
  this.phase = unselected ? 'selection' : 'entry';
30
33
  }
31
34
  }
@@ -18,11 +18,29 @@ export function compileArtifactsProfile(input) {
18
18
  const snapshot = copyJson(input);
19
19
  const profile = { ...snapshot,
20
20
  rules: snapshot.rules === undefined ? copyJson(RADAR_ARTIFACTS_PROFILE.rules) : snapshot.rules };
21
- const allowed = ['version', 'selectedCapabilities', 'operations', 'contractRevisions', 'rules'];
21
+ const allowed = ['version', 'selectedCapabilities', 'operations', 'contractRevisions', 'rules',
22
+ 'requireExpectedCurrentReleaseId', 'profileRevisionId', 'resultEnvelopeVersion'];
22
23
  if (Object.keys(profile).some((key) => !allowed.includes(key)) || profile.version !== PROFILE_CONTRACT_VERSION ||
23
24
  !Array.isArray(profile.selectedCapabilities) || !Array.isArray(profile.operations))
24
25
  throw new Error('invalid artifacts profile');
25
26
  const requested = profile.selectedCapabilities;
27
+ if (profile.profileRevisionId && profile.resultEnvelopeVersion !== 2 && requested.some(name => ['artifacts_list_versions', 'artifacts_revert'].includes(name)))
28
+ throw new Error('part 1 requires the v2 profile');
29
+ if ((profile.profileRevisionId !== undefined && (typeof profile.profileRevisionId !== 'string' ||
30
+ !profile.profileRevisionId.trim() || profile.profileRevisionId.length > 160 || profile.resultEnvelopeVersion === undefined)) ||
31
+ (profile.resultEnvelopeVersion !== undefined && (!profile.profileRevisionId ||
32
+ ![1, 2].includes(profile.resultEnvelopeVersion))) ||
33
+ (profile.resultEnvelopeVersion === 2 && (profile.requireExpectedCurrentReleaseId !== true ||
34
+ profile.contractRevisions?.outcome !== 'artifacts-outcome/v2'))) {
35
+ throw new Error('inconsistent artifact profile descriptor');
36
+ }
37
+ if (profile.requireExpectedCurrentReleaseId !== undefined && typeof profile.requireExpectedCurrentReleaseId !== 'boolean') {
38
+ throw new Error('invalid publication precondition policy');
39
+ }
40
+ if (profile.requireExpectedCurrentReleaseId && requested.some(name => [
41
+ 'artifacts_promote', 'artifacts_restore', 'artifacts_create_upload', 'artifacts_commit_upload', 'artifacts_fork',
42
+ ].includes(name)))
43
+ throw new Error('strict profile contains a writer without CAS');
26
44
  if (new Set(requested).size !== requested.length ||
27
45
  requested.some((name) => !ARTIFACT_CAPABILITY_NAMES.includes(name))) {
28
46
  throw new Error('unknown or duplicate artifacts capability');
@@ -73,13 +91,16 @@ export function compileArtifactsProfile(input) {
73
91
  throw new Error('profile/derivation key length mismatch');
74
92
  }
75
93
  const defaults = op.defaults;
94
+ if (profile.requireExpectedCurrentReleaseId && Object.hasOwn(defaults, 'expectedCurrentReleaseId')) {
95
+ throw new Error('publication preconditions cannot be defaulted');
96
+ }
76
97
  const schema = schemas[name];
77
98
  const props = schema.properties;
78
99
  for (const [key, value] of Object.entries(defaults)) {
79
100
  if (!Object.hasOwn(props, key) || !matchesSchema(value, props[key]))
80
101
  throw new Error('profile/schema default mismatch');
81
102
  }
82
- if (['artifacts_ensure', 'artifacts_publish', 'artifacts_commit_upload', 'artifacts_set_access', 'artifacts_delete'].includes(name)) {
103
+ if (['artifacts_ensure', 'artifacts_publish', 'artifacts_commit_upload', 'artifacts_set_access', 'artifacts_delete', 'artifacts_revert'].includes(name)) {
83
104
  idempotency[name] = { required: true, scope: 'principal' };
84
105
  }
85
106
  }
@@ -93,10 +114,10 @@ export function compileArtifactsProfile(input) {
93
114
  profileDigest: orderedDigest({ selection: selectedCapabilities, profile, schemas,
94
115
  providerContractRevisions: profile.contractRevisions }),
95
116
  assertSelected,
96
- validate(name, args, envelopeKey) {
117
+ validate(name, args, envelopeKey, options) {
97
118
  if (!selectedCapabilities.includes(name))
98
119
  throw new ArtifactProfileInputError(true);
99
- return normalizeArguments(profile, name, args, schemas[name], envelopeKey);
120
+ return normalizeArguments(profile, name, args, schemas[name], envelopeKey, options);
100
121
  },
101
122
  capabilityForRoute(method, path) {
102
123
  let name;
@@ -112,8 +133,16 @@ export function compileArtifactsProfile(input) {
112
133
  name = 'artifacts_set_access';
113
134
  if (method === 'POST' && path === '/delete')
114
135
  name = 'artifacts_delete';
136
+ if (method === 'POST' && path === '/revert')
137
+ name = 'artifacts_revert';
138
+ if (method === 'POST' && path === '/list_versions')
139
+ name = 'artifacts_list_versions';
115
140
  if (method === 'GET' && path.startsWith('/meta/') && isArtifactId(path.slice(6)))
116
141
  name = 'artifacts_get';
142
+ if (method === 'GET' && path.startsWith('/versions/') && isArtifactId(path.slice(10)))
143
+ name = 'artifacts_list_versions';
144
+ if (method === 'GET' && path.startsWith('/source/') && isArtifactId(path.slice(8)))
145
+ name = 'artifacts_get_source';
117
146
  return name && selectedCapabilities.includes(name) ? name : undefined;
118
147
  },
119
148
  };
@@ -10,6 +10,9 @@ export const RADAR_ARTIFACTS_PROFILE = freezeDeep({
10
10
  maxDecodedFileBytes: 262144, maxDecodedTotalBytes: 262144 }, defaults: { profile: {} } },
11
11
  { operation: 'list', limits: { listLimitMin: 1, listLimitMax: 100 }, defaults: { limit: 20, offset: 0 } },
12
12
  { operation: 'get', limits: {}, defaults: {} },
13
+ { operation: 'list_versions', limits: {}, defaults: {} },
14
+ { operation: 'get_source', limits: {}, defaults: {} },
15
+ { operation: 'revert', limits: { idempotencyKeyMaxUnits: 256 }, defaults: {} },
13
16
  ],
14
17
  rules: { lengthUnit: 'utf16', textEncoding: 'utf8', callerKeyNormalization: 'none',
15
18
  rejectLoneSurrogates: true, provenance: 'inline-original', keyDerivationAlgorithm: 'radar-derive-idempotency-v1', pathMaxBytes: 1024,
@@ -17,5 +20,5 @@ export const RADAR_ARTIFACTS_PROFILE = freezeDeep({
17
20
  reservedFirstSegments: ['api', '__artifacts', 'livez', 'health'],
18
21
  mimeTypes: ['text/html', 'text/css', 'text/javascript', 'application/json'] },
19
22
  contractRevisions: { profile: 'artifacts-profile/v1', command: 'artifacts-command/v1',
20
- authorization: 'artifacts-authorization/v1', outcome: 'artifacts-outcome/v1' },
23
+ authorization: 'artifacts-authorization/v1', outcome: 'artifacts-outcome/v2' },
21
24
  });
@@ -5,6 +5,13 @@ const obj = (properties, required = []) => ({ type: 'object', properties, requir
5
5
  const id = { type: 'string', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' };
6
6
  const primitiveRecord = { type: 'object',
7
7
  additionalProperties: { type: ['string', 'number', 'boolean', 'null'] }, default: {} };
8
+ /** Artifact display metadata: one short word for the icon. */
9
+ const metadata = {
10
+ title: { type: 'string', minLength: 1, maxLength: 160, description: 'Artifact display title.' },
11
+ description: { type: 'string', maxLength: 2000, description: 'Short artifact description.' },
12
+ icon: { type: ['string', 'null'], pattern: '^[a-z][a-z0-9-]{0,31}$',
13
+ description: 'One short lowercase word.' },
14
+ };
8
15
  /** Required configuration includes semantic limits absent from JSON Schema. */
9
16
  export function assertCompleteOperation(config) {
10
17
  const required = {
@@ -16,6 +23,7 @@ export function assertCompleteOperation(config) {
16
23
  commit_upload: { limits: ['idempotencyKeyMaxUnits'], defaults: ['profile'] },
17
24
  set_access: { limits: ['idempotencyKeyMaxUnits'], defaults: [] },
18
25
  delete: { limits: ['idempotencyKeyMaxUnits'], defaults: [] },
26
+ revert: { limits: ['idempotencyKeyMaxUnits'], defaults: [] },
19
27
  };
20
28
  const fields = required[config.operation];
21
29
  if (fields && (fields.limits.some((key) => !Object.hasOwn(config.limits, key)) ||
@@ -32,6 +40,7 @@ export function operationProfile(profile, operation) {
32
40
  /** Schemas are generated from the same limits/defaults read by normalization. */
33
41
  export function capabilitySchema(name, profile) {
34
42
  const operation = name.slice('artifacts_'.length);
43
+ const part1 = profile.profileRevisionId === undefined || profile.resultEnvelopeVersion === 2;
35
44
  const config = operationProfile(profile, operation);
36
45
  const limits = config.limits;
37
46
  const key = { type: 'string', minLength: 1, maxLength: limits.idempotencyKeyMaxUnits,
@@ -40,7 +49,7 @@ export function capabilitySchema(name, profile) {
40
49
  switch (operation) {
41
50
  case 'ensure': return obj({ bindingName: { type: 'string', minLength: limits.bindingNameMinUnits,
42
51
  maxLength: limits.bindingNameMaxUnits }, parameters: { ...obj({}), default: config.defaults.parameters },
43
- idempotencyKey: key }, ['bindingName', 'idempotencyKey']);
52
+ idempotencyKey: key, ...(part1 ? metadata : {}) }, ['bindingName', 'idempotencyKey']);
44
53
  case 'publish': {
45
54
  const reserved = profile.rules.reservedFirstSegments.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
46
55
  const path = { type: 'string', minLength: 1, maxLength: profile.rules.pathMaxBytes,
@@ -53,9 +62,20 @@ export function capabilitySchema(name, profile) {
53
62
  bytes: { type: 'array', maxItems: limits.maxDecodedFileBytes, items: integer(0, 255) } }, ['path', 'mimeType']),
54
63
  oneOf: [{ required: ['text'], not: { required: ['bytes'] } },
55
64
  { required: ['bytes'], not: { required: ['text'] } }] };
56
- return obj({ artifactId: id, idempotencyKey: key, files: { type: 'array', items: file,
57
- minItems: limits.publishFilesMin, maxItems: limits.publishFilesMax },
58
- profile: { ...primitiveRecord, default: config.defaults.profile } }, ['artifactId', 'files', 'idempotencyKey']);
65
+ const edit = obj({ path, oldStr: { type: 'string', minLength: 1, maxLength: limits.maxDecodedFileBytes,
66
+ description: 'Must occur exactly once in the current file text.' },
67
+ newStr: { type: 'string', maxLength: limits.maxDecodedFileBytes } }, ['path', 'oldStr', 'newStr']);
68
+ return { ...obj({ artifactId: id, idempotencyKey: key,
69
+ files: { type: 'array', items: file, minItems: limits.publishFilesMin, maxItems: limits.publishFilesMax,
70
+ description: 'Full file set; required unless edits is supplied.' },
71
+ ...(part1 ? { edits: { type: 'array', items: edit, minItems: 1, maxItems: 100,
72
+ description: 'Small old/new edits applied to the current release text files; required unless files is supplied.' } } : {}),
73
+ expectedCurrentReleaseId: { ...id, type: ['string', 'null'], description: 'Compare the current release atomically; null requires an unpublished artifact.' },
74
+ profile: { ...primitiveRecord, default: config.defaults.profile },
75
+ ...(part1 ? metadata : {}) }, ['artifactId', 'idempotencyKey', ...(!part1 ? ['files'] : []),
76
+ ...(profile.requireExpectedCurrentReleaseId ? ['expectedCurrentReleaseId'] : [])]),
77
+ oneOf: [{ required: ['files'], not: { required: ['edits'] } },
78
+ { required: ['edits'], not: { required: ['files'] } }] };
59
79
  }
60
80
  case 'list': return obj({ limit: { ...integer(limits.listLimitMin, limits.listLimitMax), default: config.defaults.limit },
61
81
  offset: { ...integer(0), default: config.defaults.offset } });
@@ -63,10 +83,16 @@ export function capabilitySchema(name, profile) {
63
83
  case 'fork':
64
84
  case 'renew':
65
85
  case 'restore': return idOnly;
86
+ case 'list_versions': return profile.resultEnvelopeVersion !== 2 ? idOnly : obj({ artifactId: id,
87
+ limit: { ...integer(1, 100), default: 20 }, cursor: { ...str, maxLength: 4096 } }, ['artifactId']);
66
88
  case 'get_source': return obj({ artifactId: id, releaseId: id,
67
89
  paths: { type: 'array', minItems: 1, maxItems: 32, items: { ...str, maxLength: profile.rules.pathMaxBytes } } }, ['artifactId']);
68
90
  case 'delete': return obj({ artifactId: id, expectedCurrentReleaseId: { ...id, type: ['string', 'null'] },
69
- idempotencyKey: key, confirmationRef: { ...str, maxLength: 4096 } }, ['artifactId', 'expectedCurrentReleaseId', 'idempotencyKey']);
91
+ idempotencyKey: key, confirmationRef: { ...str, maxLength: 4096 } }, ['artifactId', 'idempotencyKey', ...(profile.requireExpectedCurrentReleaseId ? ['expectedCurrentReleaseId'] : [])]);
92
+ case 'revert': {
93
+ const target = profile.resultEnvelopeVersion === 2 ? 'targetReleaseId' : 'releaseId';
94
+ return obj({ artifactId: id, [target]: id, expectedCurrentReleaseId: { ...id, type: ['string', 'null'] }, idempotencyKey: key }, ['artifactId', target, 'idempotencyKey', ...(profile.requireExpectedCurrentReleaseId ? ['expectedCurrentReleaseId'] : [])]);
95
+ }
70
96
  case 'get_template': return obj({ templateId: str }, ['templateId']);
71
97
  case 'get_api_guide': return obj({ version: str });
72
98
  case 'create_upload': return obj({ artifactId: id, mimeType: str, maxSize: integer(1) }, ['artifactId', 'mimeType']);
@@ -2,4 +2,6 @@ import { type JsonValue } from '../contracts.js';
2
2
  import { type EffectiveArtifactProfile, type ArtifactSchema } from './contracts.js';
3
3
  /** The generated schema vocabulary, evaluated without a platform dependency. */
4
4
  export declare function matchesSchema(value: unknown, schema: ArtifactSchema): boolean;
5
- export declare function normalizeArguments(profile: EffectiveArtifactProfile, name: string, input: unknown, schema: ArtifactSchema, envelopeKey?: string): Readonly<Record<string, JsonValue>>;
5
+ export declare function normalizeArguments(profile: EffectiveArtifactProfile, name: string, input: unknown, schema: ArtifactSchema, envelopeKey?: string, options?: {
6
+ deferPrecondition: true;
7
+ }): Readonly<Record<string, JsonValue>>;
@@ -72,13 +72,22 @@ export function matchesSchema(value, schema) {
72
72
  }
73
73
  return true;
74
74
  }
75
- export function normalizeArguments(profile, name, input, schema, envelopeKey) {
75
+ export function normalizeArguments(profile, name, input, schema, envelopeKey, options) {
76
76
  const args = copyJson(input);
77
+ if (profile.requireExpectedCurrentReleaseId && ['artifacts_publish', 'artifacts_revert', 'artifacts_delete'].includes(name) &&
78
+ isRecord(args) && !Object.hasOwn(args, 'expectedCurrentReleaseId')) {
79
+ if (!options?.deferPrecondition)
80
+ throw new ArtifactProfileInputError(false, true, {
81
+ profileRevisionId: profile.profileRevisionId ?? profile.version, resultEnvelopeVersion: profile.resultEnvelopeVersion ?? 1,
82
+ requiredField: 'expectedCurrentReleaseId', readTools: ['artifacts_get', 'artifacts_list'],
83
+ });
84
+ schema = { ...schema, required: schema.required.filter(key => key !== 'expectedCurrentReleaseId') };
85
+ }
77
86
  requireInput(isRecord(args) && matchesSchema(args, schema));
78
87
  const operation = name.slice('artifacts_'.length);
79
88
  const { defaults, limits } = operationProfile(profile, operation);
80
89
  const normalized = { ...copyJson(defaults), ...args };
81
- if (['ensure', 'publish', 'commit_upload', 'set_access', 'delete'].includes(operation)) {
90
+ if (['ensure', 'publish', 'commit_upload', 'set_access', 'delete', 'revert'].includes(operation)) {
82
91
  const key = args.idempotencyKey;
83
92
  requireInput(typeof key === 'string' && key.trim().length > 0 && key.length <= limits.idempotencyKeyMaxUnits);
84
93
  requireInput(envelopeKey === undefined || envelopeKey === key);
@@ -91,18 +100,28 @@ export function normalizeArguments(profile, name, input, schema, envelopeKey) {
91
100
  const paths = args.paths;
92
101
  requireInput(new Set(paths).size === paths.length && paths.every((path) => Buffer.byteLength(path, 'utf8') <= profile.rules.pathMaxBytes));
93
102
  }
103
+ if (Object.hasOwn(args, 'releaseId'))
104
+ requireInput(isArtifactId(args.releaseId));
105
+ if (Object.hasOwn(args, 'expectedCurrentReleaseId'))
106
+ requireInput(args.expectedCurrentReleaseId === null || isArtifactId(args.expectedCurrentReleaseId));
94
107
  if (operation === 'publish') {
95
- const paths = new Set();
96
- let total = 0;
97
- normalized.files = args.files.map((file) => {
98
- const path = file.path;
99
- requireInput(!paths.has(path) && Buffer.byteLength(path, 'utf8') <= profile.rules.pathMaxBytes);
100
- paths.add(path);
101
- const bytes = Object.hasOwn(file, 'text') ? Array.from(Buffer.from(file.text, 'utf8')) : file.bytes;
102
- total += bytes.length;
103
- requireInput(bytes.length <= limits.maxDecodedFileBytes && total <= limits.maxDecodedTotalBytes);
104
- return { path, mimeType: file.mimeType, bytes };
105
- });
108
+ const hasFiles = Object.hasOwn(args, 'files');
109
+ const hasEdits = Object.hasOwn(args, 'edits');
110
+ // Exactly one content source: a full file set, or small edits on the current release.
111
+ requireInput(hasFiles !== hasEdits);
112
+ if (hasFiles) {
113
+ const paths = new Set();
114
+ let total = 0;
115
+ normalized.files = args.files.map((file) => {
116
+ const path = file.path;
117
+ requireInput(!paths.has(path) && Buffer.byteLength(path, 'utf8') <= profile.rules.pathMaxBytes);
118
+ paths.add(path);
119
+ const bytes = Object.hasOwn(file, 'text') ? Array.from(Buffer.from(file.text, 'utf8')) : file.bytes;
120
+ total += bytes.length;
121
+ requireInput(bytes.length <= limits.maxDecodedFileBytes && total <= limits.maxDecodedTotalBytes);
122
+ return { path, mimeType: file.mimeType, bytes };
123
+ });
124
+ }
106
125
  }
107
126
  return freezeDeep(normalized);
108
127
  }
@@ -15,6 +15,12 @@ export interface GrantRecord {
15
15
  principalId: string;
16
16
  grant: AccessGrant;
17
17
  }
18
+ /** Display metadata of one artifact (title/description/icon); cosmetic, owner-set. */
19
+ export interface ArtifactMetadata {
20
+ title?: string;
21
+ description?: string;
22
+ icon?: string | null;
23
+ }
18
24
  export interface ArtifactAcl {
19
25
  bindingName?: string;
20
26
  createdAt?: string;
@@ -28,6 +34,8 @@ export interface ArtifactAcl {
28
34
  isPublic: boolean;
29
35
  withdrawn: boolean;
30
36
  grants: GrantRecord[];
37
+ /** Display metadata returned by get/list; persisted by the host store. */
38
+ metadata?: ArtifactMetadata;
31
39
  }
32
40
  export interface AclStore {
33
41
  getArtifactAcl(artifactId: string): Promise<ArtifactAcl | undefined>;
@@ -8,7 +8,7 @@ export type CallerIdempotencyKey = string & {
8
8
  export type InternalIdempotencyKey = string & {
9
9
  readonly __brand: 'internal-key';
10
10
  };
11
- export type ArtifactCommandOperation = 'ensure' | 'publish' | 'set_access' | 'delete';
11
+ export type ArtifactCommandOperation = 'ensure' | 'publish' | 'set_access' | 'delete' | 'revert';
12
12
  export interface CommandIdentity {
13
13
  readonly internalKey: InternalIdempotencyKey;
14
14
  readonly commandHash: string;
@@ -50,4 +50,6 @@ export declare function createRadarKeyDerivation(config: {
50
50
  }): ArtifactKeyDerivationBinding;
51
51
  export declare const commandDigest: typeof orderedDigest;
52
52
  /** No caller hash, actor, scope, transport or audit identifier enters this API through arguments. */
53
- export declare function createArtifactCommand(profile: CompiledArtifactsProfile, name: string, args: unknown, authority: ArtifactCommandAuthority, derivation: ArtifactKeyDerivationBinding, envelopeKey?: string): EffectiveArtifactCommand;
53
+ export declare function createArtifactCommand(profile: CompiledArtifactsProfile, name: string, args: unknown, authority: ArtifactCommandAuthority, derivation: ArtifactKeyDerivationBinding, envelopeKey?: string, options?: {
54
+ deferPrecondition: true;
55
+ }): EffectiveArtifactCommand;
@@ -20,7 +20,7 @@ export function createRadarKeyDerivation(config) {
20
20
  if (other === actor || !other.startsWith(`${actor}|`))
21
21
  continue;
22
22
  const [operation, objectId] = other.slice(actor.length + 1).split('|');
23
- if ((operation === 'ensure' && objectId === '') || (['publish', 'set_access', 'delete'].includes(operation) && isArtifactId(objectId))) {
23
+ if ((operation === 'ensure' && objectId === '') || (['publish', 'set_access', 'delete', 'revert'].includes(operation) && isArtifactId(objectId))) {
24
24
  throw new Error('ambiguous Radar actor domain');
25
25
  }
26
26
  }
@@ -30,7 +30,7 @@ export function createRadarKeyDerivation(config) {
30
30
  derive(input) {
31
31
  const { storageScopeId, actorId, operation, objectId, callerKey } = input;
32
32
  if (storageScopeId !== scope || !actors.has(actorId) ||
33
- !['ensure', 'publish', 'set_access', 'delete'].includes(operation) ||
33
+ !['ensure', 'publish', 'set_access', 'delete', 'revert'].includes(operation) ||
34
34
  (operation === 'ensure' ? objectId !== '' : !isArtifactId(objectId)))
35
35
  throw new ArtifactProfileInputError();
36
36
  assertWellFormed(callerKey);
@@ -43,12 +43,12 @@ export function createRadarKeyDerivation(config) {
43
43
  }
44
44
  export const commandDigest = orderedDigest;
45
45
  /** No caller hash, actor, scope, transport or audit identifier enters this API through arguments. */
46
- export function createArtifactCommand(profile, name, args, authority, derivation, envelopeKey) {
46
+ export function createArtifactCommand(profile, name, args, authority, derivation, envelopeKey, options) {
47
47
  assertCompiledArtifactsProfile(profile);
48
48
  if (derivation.algorithm !== profile.profile.rules.keyDerivationAlgorithm)
49
49
  throw new Error('profile/derivation mismatch');
50
- const normalized = profile.validate(name, args, envelopeKey);
51
- if (!['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete'].includes(name))
50
+ const normalized = profile.validate(name, args, envelopeKey, options);
51
+ if (!['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete', 'artifacts_revert'].includes(name))
52
52
  throw new ArtifactProfileInputError(true);
53
53
  const operation = name.slice('artifacts_'.length);
54
54
  const { idempotencyKey, confirmationRef: _proof, ...payload } = normalized;
@@ -407,7 +407,7 @@ export function createArtifactCommitCoordinator(options) {
407
407
  ok: false,
408
408
  failure: failure('conflict', request, phase, 'not_committed', {
409
409
  key: request.internalKey,
410
- }, { variant: 'key_contention' }),
410
+ }, { variant: 'command_conflict' }),
411
411
  };
412
412
  }
413
413
  return coordinator.replayPrepared(request);
@@ -514,7 +514,7 @@ export function createArtifactCommitCoordinator(options) {
514
514
  ok: false,
515
515
  failure: failure('conflict', request, 'lock', 'not_committed', {
516
516
  key: request.internalKey,
517
- }, { variant: 'key_contention' }),
517
+ }, { variant: 'command_conflict' }),
518
518
  };
519
519
  }
520
520
  phase = 'guard';
@@ -583,6 +583,7 @@ export function createArtifactCommitCoordinator(options) {
583
583
  if (recheckRevision !== request.policyRevision) {
584
584
  return abortWith(request, phase, settle, revokedOutcome(request));
585
585
  }
586
+ request.assertPrecondition?.();
586
587
  phase = 'commit';
587
588
  const commitFailure = await commitUnit(request, active, settle);
588
589
  if (commitFailure !== undefined)
@@ -618,6 +619,12 @@ export function createArtifactCommitCoordinator(options) {
618
619
  }
619
620
  },
620
621
  async replayPrepared(request) {
622
+ return await coordinator.findReplayPrepared(request) ?? { ok: false,
623
+ failure: failure('provider_error', request, 'lock', 'not_committed', {
624
+ lost: 'replay without a success row',
625
+ }, { variant: 'unclassified' }) };
626
+ },
627
+ async findReplayPrepared(request) {
621
628
  validateRequest(request, 'replay');
622
629
  await observeAt('before_connection_acquire', request.invocationId);
623
630
  let phase = 'acquire';
@@ -646,12 +653,8 @@ export function createArtifactCommitCoordinator(options) {
646
653
  // the barrier denies on the looked-up row instead of vanishing it.
647
654
  await observeAt('after_replay_lookup_before_guard', request.invocationId);
648
655
  if (existing === undefined) {
649
- return abortWith(request, phase, settle, {
650
- ok: false,
651
- failure: failure('provider_error', request, phase, 'not_committed', {
652
- lost: 'replay without a success row',
653
- }, { variant: 'unclassified' }),
654
- });
656
+ const error = await settle.abort();
657
+ return error === undefined ? undefined : unknownOutcome(request, phase, error);
655
658
  }
656
659
  if (existing.codecVersion !== COMMAND_CODEC_VERSION) {
657
660
  const settleError = await settle.abort();
@@ -667,7 +670,7 @@ export function createArtifactCommitCoordinator(options) {
667
670
  ok: false,
668
671
  failure: failure('conflict', request, phase, 'not_committed', {
669
672
  key: request.internalKey,
670
- }, { variant: 'key_contention' }),
673
+ }, { variant: 'command_conflict' }),
671
674
  });
672
675
  }
673
676
  phase = 'guard';
@@ -242,6 +242,8 @@ export interface FinalizeRequest {
242
242
  readonly locks?: Partial<Record<ParticipantLockClass, CommitLockFn[]>>;
243
243
  /** Provisional writes before the guard decision. */
244
244
  readonly provisional?: (client: FinalizeTxClient) => Promise<void>;
245
+ /** Domain CAS, evaluated under retained locks after current authorization. Never runs on replay. */
246
+ readonly assertPrecondition?: () => void;
245
247
  /** Intent completion inside the unit, after the success insert. */
246
248
  readonly finishIntents?: (client: FinalizeTxClient) => Promise<void>;
247
249
  /** Preparation-abandonment hook, invoked on loser/denial paths. */
@@ -372,6 +374,8 @@ export interface ArtifactCommitCoordinator {
372
374
  /** Provider-internal prepared units; domain effects remain inside the same transaction. */
373
375
  finalizePrepared(request: FinalizeRequest): Promise<FinalizeResult>;
374
376
  replayPrepared(request: FinalizeRequest): Promise<FinalizeResult>;
377
+ /** Guard an existing receipt before preparing content; absence has no side effects. */
378
+ findReplayPrepared(request: FinalizeRequest): Promise<FinalizeResult | undefined>;
375
379
  }
376
380
  /** Unknown legacy codec on a found success row: L6 projects the migration condition. */
377
381
  export declare class CommitQuarantineError extends Error {
@@ -0,0 +1,17 @@
1
+ export interface EditableFile {
2
+ readonly path: string;
3
+ readonly mimeType: string;
4
+ /** Decoded text for text mime types; undefined for binary files (not editable). */
5
+ text?: string;
6
+ readonly bytes: Uint8Array;
7
+ }
8
+ export interface EditOperation {
9
+ readonly path: string;
10
+ readonly oldStr: string;
11
+ readonly newStr: string;
12
+ }
13
+ /**
14
+ * Apply edits to a copy of the file list. Throws ArtifactOperationError
15
+ * ('edit_mismatch') on the first violation; the input files are never mutated.
16
+ */
17
+ export declare function applyEdits(files: readonly EditableFile[], edits: readonly EditOperation[]): EditableFile[];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Parity P0 — small old/new edits on publish.
3
+ *
4
+ * Edits apply to the current release's text files without resending the
5
+ * whole file set. `oldStr` must occur exactly once in the file text; any
6
+ * other count (or an unknown/non-text path) raises a typed `edit_mismatch`
7
+ * before anything is staged, so a failed call changes nothing. The edited
8
+ * files then become the new release through the same commit path.
9
+ */
10
+ import { ArtifactOperationError } from './failures.js';
11
+ function countOccurrences(haystack, needle) {
12
+ if (needle.length === 0)
13
+ return 0;
14
+ let count = 0;
15
+ let index = haystack.indexOf(needle);
16
+ while (index !== -1) {
17
+ count += 1;
18
+ index = haystack.indexOf(needle, index + Math.max(1, needle.length));
19
+ }
20
+ return count;
21
+ }
22
+ /**
23
+ * Apply edits to a copy of the file list. Throws ArtifactOperationError
24
+ * ('edit_mismatch') on the first violation; the input files are never mutated.
25
+ */
26
+ export function applyEdits(files, edits) {
27
+ const result = files.map((file) => ({ ...file }));
28
+ for (const [editIndex, edit] of edits.entries()) {
29
+ const file = result.find((entry) => entry.path === edit.path);
30
+ if (file === undefined || file.text === undefined) {
31
+ throw new ArtifactOperationError('edit_mismatch', { edit: { path: edit.path, occurrences: -1,
32
+ editIndex, reason: file === undefined ? 'file_missing' : 'not_text' } });
33
+ }
34
+ const occurrences = countOccurrences(file.text, edit.oldStr);
35
+ if (occurrences !== 1) {
36
+ throw new ArtifactOperationError('edit_mismatch', { edit: { path: edit.path, occurrences,
37
+ editIndex, reason: occurrences === 0 ? 'missing' : 'ambiguous' } });
38
+ }
39
+ file.text = file.text.slice(0, file.text.indexOf(edit.oldStr)) + edit.newStr +
40
+ file.text.slice(file.text.indexOf(edit.oldStr) + edit.oldStr.length);
41
+ }
42
+ return result;
43
+ }