@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
@@ -4,7 +4,25 @@ export type ArtifactCommitState = 'not_committed' | 'committed' | 'unknown';
4
4
  export type ArtifactInternalKey = string & {
5
5
  readonly __brand: 'internal-key';
6
6
  };
7
- export type ArtifactErrorKind = 'provider_unavailable' | 'command_conflict' | 'pointer_conflict' | 'unauthorized_at_commit' | 'capability_unselected' | 'admission_unavailable' | 'invalid_request' | 'anonymous' | 'access_denied' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed';
7
+ export type ArtifactErrorKind = 'provider_unavailable' | 'command_conflict' | 'pointer_conflict' | 'unauthorized_at_commit' | 'capability_unselected' | 'admission_unavailable' | 'invalid_request' | 'anonymous' | 'access_denied' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed' | 'edit_mismatch' | 'precondition_required';
8
+ /** Optimistic-concurrency payload: the caller re-reads and merges against this. */
9
+ export interface ArtifactConflictDetails {
10
+ readonly artifactId?: string;
11
+ readonly expectedCurrentReleaseId?: string | null;
12
+ readonly currentReleaseId: string | null;
13
+ readonly currentDigest: string | null;
14
+ readonly currentSource?: Readonly<Record<string, unknown>>;
15
+ }
16
+ /** Edit-application payload: which path failed the exactly-once rule and how often oldStr occurred. */
17
+ export interface ArtifactEditDetails {
18
+ readonly path: string;
19
+ readonly occurrences: number;
20
+ readonly editIndex?: number;
21
+ readonly reason?: 'missing' | 'ambiguous' | 'not_text' | 'file_missing';
22
+ }
23
+ export interface ArtifactReleaseUnavailableDetails {
24
+ readonly reason: 'purged' | 'not_retained' | 'unpublished';
25
+ }
8
26
  export type ArtifactFailureCategory = ArtifactFailure['category'];
9
27
  interface ArtifactFailureFields {
10
28
  readonly details?: Readonly<Record<string, unknown>>;
@@ -16,6 +34,11 @@ interface ArtifactFailureFields {
16
34
  readonly operation: string;
17
35
  readonly phase: string;
18
36
  readonly commitState: ArtifactCommitState;
37
+ /** Present on pointer/command conflicts so the caller can re-read and merge. */
38
+ readonly conflict?: ArtifactConflictDetails;
39
+ /** Present on edit_mismatch so the caller sees the failing path and occurrence count. */
40
+ readonly edit?: ArtifactEditDetails;
41
+ readonly release?: ArtifactReleaseUnavailableDetails;
19
42
  /** Original thrown value, retained by identity and deliberately non-enumerable. */
20
43
  readonly cause: unknown;
21
44
  }
@@ -25,7 +48,7 @@ export interface ArtifactOperationalFailure extends ArtifactFailureFields {
25
48
  }
26
49
  export interface ArtifactOrdinaryFailure extends Omit<ArtifactFailureFields, 'variant' | 'retriable'> {
27
50
  readonly kind: 'ordinary';
28
- readonly category: 'invalid_request' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed';
51
+ readonly category: 'invalid_request' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed' | 'edit_mismatch' | 'precondition_required';
29
52
  readonly retriable: false;
30
53
  }
31
54
  /** Entry authority outcomes are separate from ordinary and operational failures. */
@@ -67,11 +90,17 @@ export declare class ArtifactOperationError extends Error {
67
90
  readonly cause?: unknown;
68
91
  readonly refusal?: ArtifactPublicRefusal;
69
92
  readonly publicDetails?: Readonly<Record<string, unknown>>;
93
+ readonly conflict?: ArtifactConflictDetails;
94
+ readonly edit?: ArtifactEditDetails;
95
+ readonly release?: ArtifactReleaseUnavailableDetails;
70
96
  };
71
97
  constructor(kind: ArtifactErrorKind, details?: {
72
98
  readonly cause?: unknown;
73
99
  readonly refusal?: ArtifactPublicRefusal;
74
100
  readonly publicDetails?: Readonly<Record<string, unknown>>;
101
+ readonly conflict?: ArtifactConflictDetails;
102
+ readonly edit?: ArtifactEditDetails;
103
+ readonly release?: ArtifactReleaseUnavailableDetails;
75
104
  });
76
105
  }
77
106
  /** Mint only at verified per-key row contention, using the full scoped operation key. */
@@ -28,7 +28,8 @@ const UNAVAILABLE_CODES = new Set([
28
28
  const TIMEOUT_NAMES = new Set(['TimeoutError', 'RequestTimeout', 'RequestTimeoutError']);
29
29
  const DOMAIN_STATUS = {
30
30
  invalid_request: 400, anonymous: 401, access_denied: 403,
31
- not_found: 404, gone: 410, release_unavailable: 410, limit_exceeded: 413, unprocessable: 422, verification_failed: 422,
31
+ not_found: 404, gone: 410, release_unavailable: 410, limit_exceeded: 413, unprocessable: 422, verification_failed: 422, edit_mismatch: 422,
32
+ precondition_required: 428,
32
33
  };
33
34
  function field(value, name) {
34
35
  if (typeof value !== 'object' || value === null)
@@ -64,7 +65,10 @@ function operationProjection(error) {
64
65
  case 'provider_unavailable':
65
66
  case 'admission_unavailable': return unavailable(kind);
66
67
  case 'command_conflict':
67
- case 'pointer_conflict': return conflict(kind);
68
+ case 'pointer_conflict': {
69
+ const base = conflict(kind);
70
+ return error.details.conflict === undefined ? base : { ...base, conflict: error.details.conflict };
71
+ }
68
72
  case 'capability_unselected': return {
69
73
  kind: 'operational', category: kind, variant: 'unselected_operation',
70
74
  httpStatus: 503, publicCode: 'capability_unavailable', retriable: false,
@@ -86,7 +90,14 @@ function operationProjection(error) {
86
90
  case 'anonymous':
87
91
  case 'access_denied':
88
92
  return { kind: 'admission', category: kind, httpStatus: DOMAIN_STATUS[kind], publicCode: kind, retriable: false };
89
- default: return { kind: 'ordinary', category: kind, httpStatus: DOMAIN_STATUS[kind], publicCode: kind, retriable: false };
93
+ default: {
94
+ const base = { kind: 'ordinary', category: kind, httpStatus: DOMAIN_STATUS[kind], publicCode: kind, retriable: false };
95
+ if (kind === 'precondition_required')
96
+ return { ...base, details: error.details.publicDetails };
97
+ if (kind === 'release_unavailable' && error.details.release !== undefined)
98
+ return { ...base, release: error.details.release };
99
+ return kind === 'edit_mismatch' && error.details.edit !== undefined ? { ...base, edit: error.details.edit } : base;
100
+ }
90
101
  }
91
102
  }
92
103
  function classify(cause, context, mapHostError) {
@@ -55,6 +55,8 @@ export interface LifecycleService {
55
55
  listReleases(artifactId: string): Promise<string[]>;
56
56
  /** Track a published release so promotion sees it (idempotent). */
57
57
  trackRelease(artifactId: string, releaseId: string): Promise<void>;
58
+ /** Per-release lifecycle state (ready/retired/purged; ready when untracked). */
59
+ getReleaseState(releaseId: string): Promise<ReleaseState>;
58
60
  }
59
61
  /** Create a lifecycle service over an injected store (mutations owner-only). */
60
62
  export declare function createLifecycleService(store: LifecycleStore, clock?: () => string, current?: CurrentPointer, claims?: DeletionClaimStore): LifecycleService;
@@ -98,6 +98,9 @@ export function createLifecycleService(store, clock = () => new Date().toISOStri
98
98
  async stateOf(artifactId) {
99
99
  return store.getArtifactState(artifactId);
100
100
  },
101
+ async getReleaseState(releaseId) {
102
+ return store.getReleaseState(releaseId);
103
+ },
101
104
  canRead(artifact, acl, actorId, action = 'read', isApproved = () => false) {
102
105
  if (artifact.state === 'deleted' || artifact.state === 'purged')
103
106
  return false;
@@ -42,4 +42,5 @@ export declare function manageArtifact(deps: {
42
42
  readPolicyRevision(): Promise<string>;
43
43
  isApprovedPrincipal(principalId: string): boolean | Promise<boolean>;
44
44
  confirmation?: ArtifactConfirmationPort;
45
+ validateNewCommand?(): void;
45
46
  }, invocation: ArtifactInvocationContext, command: EffectiveArtifactCommand, proof?: string): Promise<ArtifactOutcome<unknown>>;
@@ -26,7 +26,7 @@ export async function manageArtifact(deps, invocation, command, proof) {
26
26
  throw refused('access_denied');
27
27
  return target;
28
28
  };
29
- const result = await deps.coordinator.finalizePrepared({
29
+ const request = {
30
30
  ...command.identity, scopeId: invocation.storageScopeId, actorId: invocation.actorId,
31
31
  invocationId: invocation.invocationId, operation: command.operation, artifactId,
32
32
  policyRevision: await deps.readPolicyRevision(), receiptData: receipt,
@@ -41,6 +41,9 @@ export async function manageArtifact(deps, invocation, command, proof) {
41
41
  throw refused('gone');
42
42
  const now = new Date().toISOString();
43
43
  if (deleting) {
44
+ // Bind omitted v1 input to the locked confirmation context, without rewriting the command.
45
+ if (!Object.hasOwn(command.payload, 'expectedCurrentReleaseId'))
46
+ context.expectedCurrentReleaseId = target.currentReleaseId;
44
47
  if (target.currentReleaseId !== context.expectedCurrentReleaseId) {
45
48
  if (proof)
46
49
  throw confirmationFailure('confirmation_stale');
@@ -73,7 +76,11 @@ export async function manageArtifact(deps, invocation, command, proof) {
73
76
  return invocation.authorizeCommit(ctx, tx);
74
77
  },
75
78
  validateReplayReceipt: (stored) => stored.receipt?.artifactId === artifactId,
76
- });
79
+ };
80
+ const replay = await deps.coordinator.findReplayPrepared(request);
81
+ if (replay === undefined)
82
+ deps.validateNewCommand?.();
83
+ const result = replay ?? await deps.coordinator.finalizePrepared(request);
77
84
  return result.ok ? { ...result, result: result.result.receipt }
78
85
  : { ok: false, failure: { ...result.failure, kind: 'operational' } };
79
86
  }
@@ -2,9 +2,9 @@
2
2
  import type { AppToolResult } from '@sentropic/mcp-platform';
3
3
  import type { HttpResponse } from '../http.js';
4
4
  import type { ArtifactFailure, ArtifactOutcome } from './failures.js';
5
- export declare function projectArtifactHttpOutcome(outcome: ArtifactOutcome<HttpResponse>): HttpResponse;
5
+ export declare function projectArtifactHttpOutcome(outcome: ArtifactOutcome<HttpResponse>, complete?: boolean): HttpResponse;
6
6
  /** Feed this application result to mcp-platform's existing result codec. No second wire protocol. */
7
- export declare function projectArtifactMcpOutcome(outcome: ArtifactOutcome<HttpResponse>, auditId: string): AppToolResult;
7
+ export declare function projectArtifactMcpOutcome(outcome: ArtifactOutcome<HttpResponse>, auditId: string, resultEnvelopeVersion?: 1 | 2): AppToolResult;
8
8
  export interface ArtifactWriteFailure {
9
9
  readonly invocationId: string;
10
10
  readonly operation: string;
@@ -1,18 +1,34 @@
1
- export function projectArtifactHttpOutcome(outcome) {
1
+ import { PRECONDITION_REQUIRED_MESSAGE } from '../profile/contracts.js';
2
+ export function projectArtifactHttpOutcome(outcome, complete = false) {
3
+ if (complete) {
4
+ const { auditId: _audit, redactionClass: _redaction, ...body } = projectArtifactMcpOutcome(outcome, '');
5
+ return { status: outcome.ok ? outcome.result.status : outcome.failure.httpStatus, body };
6
+ }
2
7
  if (outcome.ok)
3
8
  return outcome.result;
4
- return { status: outcome.failure.httpStatus, body: { error: outcome.failure.publicCode,
5
- ...(outcome.failure.details ? { details: outcome.failure.details } : {}) } };
9
+ const { failure } = outcome;
10
+ return { status: failure.httpStatus, body: { error: failure.publicCode,
11
+ ...(failure.publicCode === 'precondition_required' ? { message: PRECONDITION_REQUIRED_MESSAGE, retryable: false } : {}),
12
+ ...(failure.details ? { details: failure.details } : {}),
13
+ ...(failure.conflict !== undefined ? { conflict: failure.conflict } : {}),
14
+ ...(failure.release !== undefined ? { details: failure.release } : {}),
15
+ ...(failure.edit !== undefined ? { edit: failure.edit } : {}) } };
6
16
  }
7
17
  /** Feed this application result to mcp-platform's existing result codec. No second wire protocol. */
8
- export function projectArtifactMcpOutcome(outcome, auditId) {
18
+ export function projectArtifactMcpOutcome(outcome, auditId, resultEnvelopeVersion = 2) {
9
19
  if (outcome.ok)
10
- return { ok: true, output: outcome.result.body, auditId, redactionClass: 'low' };
11
- const { publicCode, retriable } = outcome.failure;
20
+ return { ok: true, output: outcome.result.body, auditId, redactionClass: 'low',
21
+ ...(resultEnvelopeVersion === 2 ? { invocationId: outcome.invocationId,
22
+ commitState: outcome.commitState, replayed: outcome.replayed } : {}) };
23
+ const { publicCode, retriable, conflict, edit, release, invocationId, commitState } = outcome.failure;
24
+ const variant = 'variant' in outcome.failure ? outcome.failure.variant : undefined;
25
+ const details = conflict !== undefined ? { variant: 'pointer_conflict', ...conflict }
26
+ : edit !== undefined ? { ...edit } : release !== undefined ? { ...release }
27
+ : outcome.failure.details ?? (variant !== undefined ? { variant } : undefined);
12
28
  return {
13
- ok: false, auditId, redactionClass: 'low',
14
- error: { code: publicCode, message: publicCode, retriable,
15
- ...(outcome.failure.details ? { details: outcome.failure.details } : {}) },
29
+ ok: false, auditId, redactionClass: 'low', ...(resultEnvelopeVersion === 2 ? { invocationId, commitState } : {}),
30
+ error: { code: publicCode, message: publicCode === 'precondition_required' ? PRECONDITION_REQUIRED_MESSAGE : publicCode,
31
+ retriable, ...(details === undefined ? {} : { details }) },
16
32
  };
17
33
  }
18
34
  /**
@@ -3,13 +3,16 @@ import type { IntentStore } from './object-intents.js';
3
3
  import type { IdempotencyStore } from './idempotency.js';
4
4
  import { type SourceRef, type SourceStore } from './sources.js';
5
5
  import { type PublicationLimits } from './publication-limits.js';
6
- import type { ArtifactAcl } from './access.js';
6
+ import type { ArtifactAcl, ArtifactMetadata } from './access.js';
7
7
  import type { ArtifactState, ReleaseState } from './lifecycle.js';
8
8
  import { type RefcountStore } from './gc.js';
9
9
  import type { CommandIdentity } from './command-identity.js';
10
10
  import type { AuthorizeCommit, CommitLockFn, ArtifactCommitCoordinator, FinalizeResult, FinalizeTxClient } from './commit/types.js';
11
11
  export { DEFAULT_PUBLICATION_LIMITS, type PublicationLimits } from './publication-limits.js';
12
12
  export interface ReleaseRecord {
13
+ authorPrincipalId?: string | null;
14
+ parentReleaseId?: string | null;
15
+ revertedFromReleaseId?: string | null;
13
16
  releaseId: string;
14
17
  artifactId: string;
15
18
  manifestDigest: string;
@@ -20,6 +23,8 @@ export interface ReleaseRecord {
20
23
  export interface PublicationStateStore {
21
24
  putRelease(release: ReleaseRecord): Promise<void>;
22
25
  getRelease(releaseId: string): Promise<ReleaseRecord | undefined>;
26
+ /** Full release history of one artifact, oldest first (creation order, then id). */
27
+ listReleases(artifactId: string): Promise<ReleaseRecord[]>;
23
28
  getCurrentReleaseId(artifactId: string): Promise<string | undefined>;
24
29
  compareAndSwapCurrent(artifactId: string, expected: string | null, next: string): Promise<boolean>;
25
30
  }
@@ -63,6 +68,8 @@ export interface PublicationService {
63
68
  publish(input: PublishInput, sources?: SourceStore): Promise<PublishResult>;
64
69
  getCurrent(artifactId: string): Promise<ReleaseRecord | undefined>;
65
70
  getRelease(releaseId: string): Promise<ReleaseRecord | undefined>;
71
+ /** Release history records (digest, manifest, createdAt) for list_versions. */
72
+ listReleases(artifactId: string): Promise<ReleaseRecord[]>;
66
73
  }
67
74
  /** Create a publication service over injected stores (no driver imports). */
68
75
  export declare function createPublicationService(deps: {
@@ -103,6 +110,12 @@ export declare function decidePublishAuthorization(input: PublishAuthorizationIn
103
110
  };
104
111
  /** Transaction-scoped provisional writers for the finalized unit. */
105
112
  export interface PublicationUnitWriter {
113
+ /** Lock the artifact row, including when no pointer exists, then read the current release. */
114
+ lockCurrent(artifactId: string): Promise<{
115
+ currentReleaseId: string | null;
116
+ currentDigest: string | null;
117
+ }>;
118
+ updateMetadata(artifactId: string, metadata: ArtifactMetadata): Promise<ArtifactMetadata>;
106
119
  readAuthorization(artifactId: string): Promise<{
107
120
  state: ArtifactState;
108
121
  acl: ArtifactAcl | undefined;
@@ -117,6 +130,9 @@ export interface PublicationUnitWriter {
117
130
  /** PostgreSQL unit writer over the coordinator's leased client. */
118
131
  export declare function createPostgresPublicationUnitWriter(client: FinalizeTxClient): PublicationUnitWriter;
119
132
  export interface GuardedPublishInput extends PublishInput {
133
+ readonly expectedCurrentReleaseId?: string | null;
134
+ readonly metadata?: ArtifactMetadata;
135
+ readonly operation?: 'publish' | 'revert';
120
136
  /** Frozen trusted identity supplied by the host's L1 command normalizer. */
121
137
  readonly identity: CommandIdentity;
122
138
  readonly actorId: string;
@@ -141,6 +157,7 @@ export interface GuardedPublicationDeps {
141
157
  readonly limits?: Partial<PublicationLimits>;
142
158
  }
143
159
  export interface GuardedPublicationService {
160
+ replayFinalized(input: Omit<GuardedPublishInput, 'files' | 'source' | 'profile'>): Promise<FinalizeResult | undefined>;
144
161
  publishFinalized(input: GuardedPublishInput): Promise<FinalizeResult>;
145
162
  }
146
163
  /** Create a publication service whose commits finalize through L3 ports. */
@@ -16,6 +16,7 @@ import { assertBlobPrefix } from './blob-prefix.js';
16
16
  import { canonicalManifest, manifestDigest } from '../codecs.js';
17
17
  import { parseSourceRef } from './sources.js';
18
18
  import { publicationLimits, validatePublicationFiles } from './publication-limits.js';
19
+ import { ArtifactOperationError } from './failures.js';
19
20
  import { createPostgresDeletionClaimStore } from './gc.js';
20
21
  export { DEFAULT_PUBLICATION_LIMITS } from './publication-limits.js';
21
22
  /** Derive the `sha256:<hex>` digest from a digest-addressed blob key. */
@@ -37,6 +38,12 @@ export function createMemoryStateStore() {
37
38
  getRelease(releaseId) {
38
39
  return Promise.resolve(releases.get(releaseId));
39
40
  },
41
+ listReleases(artifactId) {
42
+ // Stable sort: createdAt ties keep insertion (creation) order.
43
+ return Promise.resolve([...releases.values()]
44
+ .filter((release) => release.artifactId === artifactId)
45
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0)));
46
+ },
40
47
  getCurrentReleaseId(artifactId) {
41
48
  return Promise.resolve(current.get(artifactId));
42
49
  },
@@ -176,6 +183,9 @@ export function createPublicationService(deps) {
176
183
  return undefined;
177
184
  return release;
178
185
  },
186
+ async listReleases(artifactId) {
187
+ return deps.state.listReleases(artifactId);
188
+ },
179
189
  };
180
190
  }
181
191
  /**
@@ -199,6 +209,17 @@ export function decidePublishAuthorization(input) {
199
209
  /** PostgreSQL unit writer over the coordinator's leased client. */
200
210
  export function createPostgresPublicationUnitWriter(client) {
201
211
  return {
212
+ async lockCurrent(artifactId) {
213
+ await client.query('SELECT id FROM artifacts.artifacts WHERE id = $1 FOR UPDATE', [artifactId]);
214
+ const { rows } = await client.query(`SELECT c.release_id, r.manifest_digest FROM artifacts.artifact_current c
215
+ JOIN artifacts.releases r ON r.id = c.release_id WHERE c.artifact_id = $1`, [artifactId]);
216
+ return { currentReleaseId: rows[0]?.release_id ?? null, currentDigest: rows[0]?.manifest_digest ?? null };
217
+ },
218
+ async updateMetadata(artifactId, metadata) {
219
+ const { rows } = await client.query(`UPDATE artifacts.artifacts SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb
220
+ WHERE id = $1 RETURNING metadata`, [artifactId, JSON.stringify(metadata)]);
221
+ return rows[0]?.metadata ?? {};
222
+ },
202
223
  async readAuthorization(artifactId) {
203
224
  const { rows } = await client.query('SELECT lifecycle_state, owner_id FROM artifacts.artifacts WHERE id = $1', [artifactId]);
204
225
  const row = rows[0];
@@ -213,8 +234,10 @@ export function createPostgresPublicationUnitWriter(client) {
213
234
  source.kind === 'recipe' ? JSON.stringify(source.steps) : null]);
214
235
  },
215
236
  async putRelease(release) {
216
- await client.query(`INSERT INTO artifacts.releases (id, artifact_id, manifest_digest, manifest, state, created_at)
217
- VALUES ($1, $2, $3, $4, 'ready', $5)`, [release.releaseId, release.artifactId, release.manifestDigest, release.manifest, release.createdAt]);
237
+ await client.query(`INSERT INTO artifacts.releases (id, artifact_id, manifest_digest, manifest, state, created_at,
238
+ author_principal_id, parent_release_id, reverted_from_release_id)
239
+ VALUES ($1, $2, $3, $4, 'ready', $5, $6, $7, $8)`, [release.releaseId, release.artifactId, release.manifestDigest, release.manifest, release.createdAt,
240
+ release.authorPrincipalId ?? null, release.parentReleaseId ?? null, release.revertedFromReleaseId ?? null]);
218
241
  },
219
242
  async registerBlob(storageScopeId, digest, size) {
220
243
  await client.query(`INSERT INTO artifacts.blobs (storage_scope_id, digest, size)
@@ -280,6 +303,16 @@ export function createGuardedPublicationService(deps) {
280
303
  return { allowed: true, fence: transaction.participant.mintFence(revision, context.invocationId) };
281
304
  };
282
305
  return {
306
+ async replayFinalized(input) {
307
+ let client;
308
+ return deps.coordinator.findReplayPrepared({ ...input.identity,
309
+ scopeId: input.storageScopeId, invocationId: input.invocationId, actorId: input.actorId,
310
+ artifactId: input.artifactId, operation: input.operation ?? 'publish', policyRevision: input.policyRevision,
311
+ receiptData: null, locks: { ...deps.locks, 'artifact-lifecycle': [
312
+ async (tx) => { client = tx; await deps.createUnitWriter(tx).lockCurrent(input.artifactId); },
313
+ ...(deps.locks?.['artifact-lifecycle'] ?? []),
314
+ ] }, guard: guard(() => client, input.authorizeCommit), });
315
+ },
283
316
  async publishFinalized(input) {
284
317
  validatePublicationFiles(input.files, limits);
285
318
  const prepared = preparePublication(input);
@@ -321,6 +354,10 @@ export function createGuardedPublicationService(deps) {
321
354
  const addedRefs = [];
322
355
  const source = input.source === undefined ? undefined : parseSourceRef(input.source);
323
356
  let finalizationClient;
357
+ let current;
358
+ const revertedFromReleaseId = input.operation === 'revert' && source?.kind === 'base-commit' ? source.commit : null;
359
+ const receiptData = { releaseId, manifest: prepared.manifest, metadata: {},
360
+ previousReleaseId: null, updatedAt: new Date(nowMs).toISOString(), revertedFromReleaseId };
324
361
  const request = {
325
362
  scopeId: input.storageScopeId,
326
363
  internalKey,
@@ -328,18 +365,29 @@ export function createGuardedPublicationService(deps) {
328
365
  codecVersion,
329
366
  invocationId,
330
367
  actorId: input.actorId,
331
- operation: 'publish',
368
+ operation: input.operation ?? 'publish',
332
369
  artifactId: input.artifactId,
333
370
  bindingName: null,
334
371
  policyRevision: input.policyRevision,
335
372
  quotaBytes: prepared.entries.reduce((total, entry) => total + entry.bytes.byteLength, 0),
336
373
  locks: { ...deps.locks, 'artifact-lifecycle': [
337
- async (client) => { finalizationClient = client; },
374
+ async (client) => {
375
+ finalizationClient = client;
376
+ current = await deps.createUnitWriter(client).lockCurrent(input.artifactId);
377
+ },
338
378
  ...(deps.locks?.['artifact-lifecycle'] ?? []),
339
379
  ] },
380
+ assertPrecondition: () => {
381
+ if (input.expectedCurrentReleaseId !== undefined && current.currentReleaseId !== input.expectedCurrentReleaseId) {
382
+ throw new ArtifactOperationError('pointer_conflict', { conflict: { ...current,
383
+ artifactId: input.artifactId, expectedCurrentReleaseId: input.expectedCurrentReleaseId } });
384
+ }
385
+ },
340
386
  provisional: async (client) => {
341
387
  const unit = deps.createUnitWriter(client);
388
+ receiptData.previousReleaseId = current.currentReleaseId;
342
389
  await unit.putRelease({
390
+ authorPrincipalId: input.actorId, parentReleaseId: current.currentReleaseId, revertedFromReleaseId,
343
391
  releaseId,
344
392
  artifactId: input.artifactId,
345
393
  manifestDigest: prepared.digest,
@@ -353,6 +401,7 @@ export function createGuardedPublicationService(deps) {
353
401
  addedRefs.push(staged.entry.digest);
354
402
  }
355
403
  await unit.promoteCurrentLatestWins(input.artifactId, releaseId);
404
+ receiptData.metadata = await unit.updateMetadata(input.artifactId, input.metadata ?? {});
356
405
  if (source !== undefined)
357
406
  await unit.putSource(input.artifactId, source);
358
407
  },
@@ -370,7 +419,7 @@ export function createGuardedPublicationService(deps) {
370
419
  await deps.noteAbandoned?.(attemptId);
371
420
  },
372
421
  attemptId,
373
- receiptData: { releaseId, manifest: prepared.manifest },
422
+ receiptData,
374
423
  };
375
424
  preservePins = true;
376
425
  const outcome = await deps.coordinator.finalizePrepared(request);
@@ -0,0 +1,14 @@
1
+ export declare const ARTIFACT_RESULT_SCHEMA: {
2
+ readonly type: "object";
3
+ readonly oneOf: readonly [{
4
+ type: string;
5
+ properties: Record<string, unknown>;
6
+ required: string[];
7
+ additionalProperties: boolean;
8
+ }, {
9
+ type: string;
10
+ properties: Record<string, unknown>;
11
+ required: string[];
12
+ additionalProperties: boolean;
13
+ }];
14
+ };
@@ -0,0 +1,35 @@
1
+ /** Published domain-result envelope; error details are selected by code and variant. */
2
+ const string = { type: 'string' };
3
+ const nullable = { type: ['string', 'null'] };
4
+ const object = (properties, required = Object.keys(properties)) => ({
5
+ type: 'object', properties, required, additionalProperties: false,
6
+ });
7
+ const error = (code, details) => object({
8
+ code: { enum: [code] }, message: string, retriable: { type: 'boolean' }, details,
9
+ });
10
+ const pointer = object({ variant: { enum: ['pointer_conflict'] }, artifactId: string,
11
+ expectedCurrentReleaseId: nullable, currentReleaseId: nullable, currentDigest: nullable,
12
+ currentSource: object({ artifactId: string, releaseId: string, digest: nullable,
13
+ manifest: string, files: { type: 'array', items: { type: 'object' } }, sourceRef: { type: ['object', 'null'] },
14
+ stableUrl: string, pinnedUrl: string }, ['artifactId', 'releaseId', 'digest', 'manifest', 'files', 'sourceRef']),
15
+ }, ['variant', 'artifactId', 'expectedCurrentReleaseId', 'currentReleaseId', 'currentDigest']);
16
+ const common = { invocationId: string,
17
+ commitState: { enum: ['not_committed', 'committed', 'unknown'] } };
18
+ export const ARTIFACT_RESULT_SCHEMA = {
19
+ type: 'object',
20
+ oneOf: [
21
+ object({ ok: { enum: [true] }, ...common, replayed: { type: 'boolean' }, output: { type: 'object' } }),
22
+ object({ ok: { enum: [false] }, ...common, error: { oneOf: [
23
+ error('conflict', { oneOf: [pointer, object({ variant: { enum: ['command_conflict', 'key_contention', 'quota_exhausted'] } })] }),
24
+ error('edit_mismatch', object({ path: string, editIndex: { type: 'integer', minimum: 0 },
25
+ reason: { enum: ['missing', 'ambiguous', 'not_text', 'file_missing'] }, occurrences: { type: 'integer' } })),
26
+ error('release_unavailable', object({ reason: { enum: ['purged', 'not_retained', 'unpublished'] } })),
27
+ error('precondition_required', object({ profileRevisionId: string, resultEnvelopeVersion: { enum: [1, 2] },
28
+ requiredField: { enum: ['expectedCurrentReleaseId'] }, readTools: { type: 'array', items: string } })),
29
+ error('confirmation_required', object({ confirmationRef: string, managementUrl: string, expiresAt: string })),
30
+ object({ code: { type: 'string', not: { enum: ['conflict', 'edit_mismatch', 'release_unavailable',
31
+ 'precondition_required', 'confirmation_required'] } },
32
+ message: string, retriable: { type: 'boolean' }, details: object({ variant: string }) }, ['code', 'message', 'retriable']),
33
+ ] } }),
34
+ ],
35
+ };
@@ -3,6 +3,7 @@ import { type CompiledArtifactsProfile } from '../profile/index.js';
3
3
  import { type ArtifactKeyDerivationBinding } from './command-identity.js';
4
4
  import type { ArtifactInvocationContext, ArtifactCommitCoordinator, FinalizeTxClient, InternalIdempotencyKey } from './commit/types.js';
5
5
  import type { ArtifactOutcome } from './failures.js';
6
+ import type { ArtifactMetadata } from './access.js';
6
7
  export interface EnsurePrivateViewInput {
7
8
  actorUserId: string;
8
9
  ownerId: string;
@@ -48,6 +49,7 @@ export interface GuardedEnsureResult {
48
49
  }
49
50
  /** Binding identity resolved inside the finalization unit. */
50
51
  export interface EnsureBindingInput {
52
+ readonly metadata?: ArtifactMetadata;
51
53
  readonly ownerId: string;
52
54
  readonly storageScopeId: string;
53
55
  readonly bindingName: string;
@@ -88,7 +88,9 @@ const ensureResult = (receipt, replayed, invocationId) => ({
88
88
  });
89
89
  export function createGuardedEnsureService(deps) {
90
90
  const finalizeRequest = (invocation, command, policyRevision) => {
91
+ const metadata = Object.fromEntries(['title', 'description', 'icon'].filter(key => Object.hasOwn(command.payload, key)).map(key => [key, command.payload[key]]));
91
92
  const binding = {
93
+ ...(Object.keys(metadata).length ? { metadata } : {}),
92
94
  ownerId: invocation.actorId,
93
95
  storageScopeId: invocation.storageScopeId,
94
96
  bindingName: command.payload.bindingName,
@@ -20,9 +20,7 @@ export declare function artifactSummary(input: {
20
20
  write: boolean;
21
21
  manage: boolean;
22
22
  };
23
- icon?: string | null | undefined;
24
- description?: string | undefined;
25
- title?: string | undefined;
23
+ metadata?: import("./access.js").ArtifactMetadata | undefined;
26
24
  artifactId: string;
27
25
  bindingName: string | null;
28
26
  ownerId: string;
@@ -36,4 +34,7 @@ export declare function artifactSummary(input: {
36
34
  digest: string | null;
37
35
  stableUrl: string | null;
38
36
  pinnedUrl: string | null;
37
+ title: string;
38
+ description: string;
39
+ icon: string | null;
39
40
  };
@@ -9,9 +9,10 @@ export function artifactSummary(input) {
9
9
  createdAt: acl.createdAt ?? null, updatedAt: acl.updatedAt ?? null,
10
10
  currentReleaseId: current?.releaseId ?? null, digest: current ? parseManifest(current.manifest).digest ?? null : null,
11
11
  stableUrl: urls?.stableUrl ?? null, pinnedUrl: current ? urls?.pinnedUrl ?? null : null,
12
- ...(acl.title === undefined ? {} : { title: acl.title }),
13
- ...(acl.description === undefined ? {} : { description: acl.description }),
14
- ...(acl.icon === undefined ? {} : { icon: acl.icon }),
12
+ title: acl.metadata?.title ?? acl.title ?? acl.bindingName ?? artifactId,
13
+ description: acl.metadata?.description ?? acl.description ?? '',
14
+ icon: acl.metadata?.icon !== undefined ? acl.metadata.icon : acl.icon ?? null,
15
+ ...(acl.metadata === undefined ? {} : { metadata: acl.metadata }),
15
16
  permissions: { read: evaluateAccess(acl, actorId, 'read', input.approved),
16
17
  source: evaluateAccess(acl, actorId, 'source', input.approved), write: owner, manage: owner } };
17
18
  }
@@ -44,11 +44,32 @@ const GUIDE_V1 = [
44
44
  'Publish and share static web pages through the `artifacts_*` tools:',
45
45
  '',
46
46
  '- `artifacts_get_template` / `artifacts_get_api_guide`: start here.',
47
- '- `artifacts_ensure`: resolve (or create) your private artifact view.',
48
- '- `artifacts_publish`: publish inline files as a new release.',
47
+ '- `artifacts_ensure`: resolve (or create) your private artifact view;',
48
+ ' optional `title`, `description` and `icon` (one short word) describe it.',
49
+ '- `artifacts_publish`: publish inline `files` as a new release, or apply',
50
+ ' small `edits` (`[{path, oldStr, newStr}]`) to the current release\'s',
51
+ ' text files — `oldStr` must occur exactly once per file, otherwise the',
52
+ ' call fails with `edit_mismatch` and changes nothing. Both accept',
53
+ ' optional metadata and `expectedCurrentReleaseId`: when set and not the',
54
+ ' current release, the call fails with a `conflict` error carrying the',
55
+ ' current release id and digest so you can re-read and merge.',
56
+ ' Strict profiles require this field on publish and revert; null means unpublished.',
57
+ ' Edits target that observed release, with at most 100 ordered substitutions.',
58
+ ' Reuse the exact idempotency key after a lost response, including commitState unknown.',
59
+ ' Replays precede edits and CAS; changing metadata, edits or the precondition under',
60
+ ' the same key returns conflict with variant command_conflict.',
61
+ ' MCP returns the complete artifacts-outcome/v2 JSON envelope: ok, invocationId,',
62
+ ' commitState, replayed/output on success, or typed error details on failure.',
49
63
  '- `artifacts_create_upload` + bounded HTTP append +',
50
64
  ' `artifacts_commit_upload`: stage large bytes, then commit them.',
51
- '- `artifacts_list` / `artifacts_get` / `artifacts_get_source`: discover.',
65
+ '- `artifacts_list` / `artifacts_get`: discover (metadata included).',
66
+ '- `artifacts_list_versions`: full release history of one artifact —',
67
+ ' per release: id, created at, author, state, whether it is current,',
68
+ ' and a content digest.',
69
+ '- `artifacts_get_source`: current file contents (text decoded) for editing.',
70
+ '- `artifacts_revert`: make an earlier release current again as a NEW',
71
+ ' release that copies its content — the artifact URL stays stable and',
72
+ ' history only grows. Purged releases are refused with `release_unavailable`.',
52
73
  '- `artifacts_fork`: private independent copy (no ACL copied).',
53
74
  '- `artifacts_set_access`: grant/revoke read or source, toggle public.',
54
75
  '- `artifacts_promote`: explicit version selection (expected-current).',
@@ -0,0 +1,29 @@
1
+ import type { ReleaseRecord } from './publication.js';
2
+ /** A cursor fixes the release boundary and is bound to the authorized artifact and scope. */
3
+ export declare function artifactVersions(input: {
4
+ artifactId: string;
5
+ storageScopeId: string;
6
+ currentReleaseId: string | null;
7
+ releases: ReleaseRecord[];
8
+ limit?: number;
9
+ cursor?: string;
10
+ state(releaseId: string): Promise<string>;
11
+ urls?: (artifactId: string, releaseId: string) => {
12
+ pinnedUrl: string;
13
+ };
14
+ }): Promise<{
15
+ artifactId: string;
16
+ currentReleaseId: string | null;
17
+ versions: {
18
+ releaseId: string;
19
+ createdAt: string;
20
+ state: string;
21
+ authorPrincipalId: string | null;
22
+ parentReleaseId: string | null;
23
+ revertedFromReleaseId: string | null;
24
+ digest: string;
25
+ isCurrent: boolean;
26
+ pinnedUrl: string | null;
27
+ }[];
28
+ nextCursor: string | null;
29
+ }>;