@sentropic/artifacts 0.2.4 → 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 (50) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +51 -3
  3. package/dist/adapters/postgres-management.d.ts +4 -0
  4. package/dist/adapters/postgres-management.js +36 -0
  5. package/dist/adapters/postgres.js +30 -8
  6. package/dist/http.d.ts +11 -1
  7. package/dist/http.js +272 -38
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.js +3 -0
  10. package/dist/mcp-core.d.ts +3 -3
  11. package/dist/mcp-core.js +29 -10
  12. package/dist/profile/contracts.d.ts +15 -5
  13. package/dist/profile/contracts.js +9 -6
  14. package/dist/profile/index.js +39 -4
  15. package/dist/profile/radar.js +4 -1
  16. package/dist/profile/schemas.js +41 -8
  17. package/dist/profile/validation.d.ts +3 -1
  18. package/dist/profile/validation.js +36 -13
  19. package/dist/server/access.d.ts +14 -0
  20. package/dist/server/command-identity.d.ts +6 -3
  21. package/dist/server/command-identity.js +8 -8
  22. package/dist/server/commit/coordinator.js +13 -10
  23. package/dist/server/commit/types.d.ts +4 -0
  24. package/dist/server/edits.d.ts +17 -0
  25. package/dist/server/edits.js +43 -0
  26. package/dist/server/failures.d.ts +34 -2
  27. package/dist/server/failures.js +17 -3
  28. package/dist/server/lifecycle.d.ts +4 -0
  29. package/dist/server/lifecycle.js +15 -0
  30. package/dist/server/management.d.ts +46 -0
  31. package/dist/server/management.js +86 -0
  32. package/dist/server/outcome-projection.d.ts +2 -2
  33. package/dist/server/outcome-projection.js +25 -7
  34. package/dist/server/publication.d.ts +22 -11
  35. package/dist/server/publication.js +56 -7
  36. package/dist/server/result-schema.d.ts +14 -0
  37. package/dist/server/result-schema.js +35 -0
  38. package/dist/server/service.d.ts +2 -0
  39. package/dist/server/service.js +2 -0
  40. package/dist/server/source-read.d.ts +38 -0
  41. package/dist/server/source-read.js +68 -0
  42. package/dist/server/summary.d.ts +40 -0
  43. package/dist/server/summary.js +18 -0
  44. package/dist/server/templates.js +24 -3
  45. package/dist/server/versions.d.ts +29 -0
  46. package/dist/server/versions.js +39 -0
  47. package/docs/migration-l6.md +8 -1
  48. package/migrations/0004_terminal_controls.sql +47 -0
  49. package/migrations/0005_metadata.sql +14 -0
  50. package/package.json +15 -2
@@ -4,9 +4,28 @@ 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' | '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 {
28
+ readonly details?: Readonly<Record<string, unknown>>;
10
29
  readonly variant?: string;
11
30
  readonly httpStatus: number;
12
31
  readonly publicCode: string;
@@ -15,6 +34,11 @@ interface ArtifactFailureFields {
15
34
  readonly operation: string;
16
35
  readonly phase: string;
17
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;
18
42
  /** Original thrown value, retained by identity and deliberately non-enumerable. */
19
43
  readonly cause: unknown;
20
44
  }
@@ -24,7 +48,7 @@ export interface ArtifactOperationalFailure extends ArtifactFailureFields {
24
48
  }
25
49
  export interface ArtifactOrdinaryFailure extends Omit<ArtifactFailureFields, 'variant' | 'retriable'> {
26
50
  readonly kind: 'ordinary';
27
- readonly category: 'invalid_request' | 'not_found' | 'gone' | 'unprocessable' | 'verification_failed';
51
+ readonly category: 'invalid_request' | 'not_found' | 'gone' | 'release_unavailable' | 'limit_exceeded' | 'unprocessable' | 'verification_failed' | 'edit_mismatch' | 'precondition_required';
28
52
  readonly retriable: false;
29
53
  }
30
54
  /** Entry authority outcomes are separate from ordinary and operational failures. */
@@ -65,10 +89,18 @@ export declare class ArtifactOperationError extends Error {
65
89
  readonly details: {
66
90
  readonly cause?: unknown;
67
91
  readonly refusal?: ArtifactPublicRefusal;
92
+ readonly publicDetails?: Readonly<Record<string, unknown>>;
93
+ readonly conflict?: ArtifactConflictDetails;
94
+ readonly edit?: ArtifactEditDetails;
95
+ readonly release?: ArtifactReleaseUnavailableDetails;
68
96
  };
69
97
  constructor(kind: ArtifactErrorKind, details?: {
70
98
  readonly cause?: unknown;
71
99
  readonly refusal?: ArtifactPublicRefusal;
100
+ readonly publicDetails?: Readonly<Record<string, unknown>>;
101
+ readonly conflict?: ArtifactConflictDetails;
102
+ readonly edit?: ArtifactEditDetails;
103
+ readonly release?: ArtifactReleaseUnavailableDetails;
72
104
  });
73
105
  }
74
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, 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) {
@@ -126,6 +137,9 @@ export function classifyArtifactFailure(cause, context, mapHostError) {
126
137
  projection = unavailable();
127
138
  const failure = {
128
139
  ...projection, invocationId: context.invocationId, operation: context.operation,
140
+ ...(cause instanceof ArtifactOperationError && cause.details.publicDetails &&
141
+ ['confirmation_required', 'confirmation_invalid', 'confirmation_expired', 'confirmation_stale', 'release_unavailable'].includes(projection.publicCode)
142
+ ? { details: cause.details.publicDetails } : {}),
129
143
  phase: context.phase, commitState: context.commitState, cause,
130
144
  };
131
145
  Object.defineProperty(failure, 'cause', { value: cause, enumerable: false });
@@ -12,6 +12,8 @@ export type ArtifactState = 'active' | 'withdrawn' | 'deleted' | 'purged';
12
12
  /** Release states use `ready` (not `active`) to match the releases table. */
13
13
  export type ReleaseState = 'ready' | 'retired' | 'purged';
14
14
  export interface LifecycleStore {
15
+ isTerminal(artifactId: string): Promise<boolean>;
16
+ markTerminal(artifactId: string): Promise<void>;
15
17
  register(artifactId: string, ownerId: string): Promise<void>;
16
18
  getOwner(artifactId: string): Promise<string | undefined>;
17
19
  getArtifactState(artifactId: string): Promise<ArtifactState>;
@@ -53,6 +55,8 @@ export interface LifecycleService {
53
55
  listReleases(artifactId: string): Promise<string[]>;
54
56
  /** Track a published release so promotion sees it (idempotent). */
55
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>;
56
60
  }
57
61
  /** Create a lifecycle service over an injected store (mutations owner-only). */
58
62
  export declare function createLifecycleService(store: LifecycleStore, clock?: () => string, current?: CurrentPointer, claims?: DeletionClaimStore): LifecycleService;
@@ -16,6 +16,14 @@ export function createMemoryLifecycleStore() {
16
16
  const artifacts = new Map();
17
17
  const releaseStates = new Map();
18
18
  const store = {
19
+ async isTerminal(id) { return artifacts.get(id)?.terminal === true; },
20
+ async markTerminal(id) {
21
+ const entry = artifacts.get(id);
22
+ if (!entry)
23
+ throw new Error('unknown artifact');
24
+ entry.terminal = true;
25
+ entry.state = 'deleted';
26
+ },
19
27
  async register(artifactId, ownerId) {
20
28
  const entry = artifacts.get(artifactId);
21
29
  if (entry !== undefined) {
@@ -35,6 +43,8 @@ export function createMemoryLifecycleStore() {
35
43
  const entry = artifacts.get(artifactId);
36
44
  if (entry === undefined)
37
45
  throw new Error(`unknown artifact: ${artifactId}`);
46
+ if (entry.terminal && state !== 'deleted' && state !== 'purged')
47
+ throw new Error('terminal artifact');
38
48
  entry.state = state;
39
49
  },
40
50
  async getRenewedAt(artifactId) {
@@ -88,6 +98,9 @@ export function createLifecycleService(store, clock = () => new Date().toISOStri
88
98
  async stateOf(artifactId) {
89
99
  return store.getArtifactState(artifactId);
90
100
  },
101
+ async getReleaseState(releaseId) {
102
+ return store.getReleaseState(releaseId);
103
+ },
91
104
  canRead(artifact, acl, actorId, action = 'read', isApproved = () => false) {
92
105
  if (artifact.state === 'deleted' || artifact.state === 'purged')
93
106
  return false;
@@ -108,6 +121,8 @@ export function createLifecycleService(store, clock = () => new Date().toISOStri
108
121
  },
109
122
  async restore(artifactId, actorId) {
110
123
  await assertOwner(artifactId, actorId);
124
+ if (await store.isTerminal(artifactId))
125
+ throw new Error('cannot restore terminal artifact');
111
126
  const state = await store.getArtifactState(artifactId);
112
127
  if (state === 'purged')
113
128
  throw new Error(`cannot restore purged artifact: terminal ${artifactId}`);
@@ -0,0 +1,46 @@
1
+ import type { ArtifactAcl } from './access.js';
2
+ import type { ArtifactInvocationContext, ArtifactCommitCoordinator, FinalizeTxClient } from './commit/types.js';
3
+ import type { EffectiveArtifactCommand } from './command-identity.js';
4
+ import { ArtifactOperationError, type ArtifactOutcome } from './failures.js';
5
+ export interface ManagementTarget {
6
+ acl: ArtifactAcl;
7
+ state: string;
8
+ terminal: boolean;
9
+ currentReleaseId: string | null;
10
+ }
11
+ export interface ManagementUnit {
12
+ lock(artifactId: string): Promise<void>;
13
+ read(artifactId: string): Promise<ManagementTarget | undefined>;
14
+ setAccess(artifactId: string, principalId: string, action: 'grant' | 'revoke', updatedAt: string): Promise<{
15
+ grants: unknown;
16
+ updatedAt: string | null;
17
+ }>;
18
+ delete(artifactId: string, deletedAt: string): Promise<void>;
19
+ }
20
+ export interface DeleteConfirmationContext {
21
+ actorId: string;
22
+ storageScopeId: string;
23
+ artifactId: string;
24
+ expectedCurrentReleaseId: string | null;
25
+ commandHash: string;
26
+ internalKey: string;
27
+ }
28
+ /** Host challenges require trusted human approval; consume on the domain transaction. */
29
+ export interface ArtifactConfirmationPort {
30
+ request(context: DeleteConfirmationContext): Promise<{
31
+ confirmationRef: string;
32
+ managementUrl: string;
33
+ expiresAt: string;
34
+ }>;
35
+ consume(client: FinalizeTxClient, context: DeleteConfirmationContext, confirmationRef: string): Promise<'valid' | 'invalid' | 'expired' | 'stale'>;
36
+ }
37
+ export declare function confirmationFailure(code: string, details?: Record<string, unknown>): ArtifactOperationError;
38
+ /** Targeted writes and proof consumption share the existing coordinator receipt transaction. */
39
+ export declare function manageArtifact(deps: {
40
+ coordinator: ArtifactCommitCoordinator;
41
+ createUnit(client: FinalizeTxClient): ManagementUnit;
42
+ readPolicyRevision(): Promise<string>;
43
+ isApprovedPrincipal(principalId: string): boolean | Promise<boolean>;
44
+ confirmation?: ArtifactConfirmationPort;
45
+ validateNewCommand?(): void;
46
+ }, invocation: ArtifactInvocationContext, command: EffectiveArtifactCommand, proof?: string): Promise<ArtifactOutcome<unknown>>;
@@ -0,0 +1,86 @@
1
+ import { ArtifactOperationError } from './failures.js';
2
+ export function confirmationFailure(code, details) {
3
+ return new ArtifactOperationError('unauthorized_at_commit', { publicDetails: details,
4
+ refusal: { allowed: false, reason: 'denied', publicCode: code,
5
+ status: code === 'confirmation_invalid' ? 403 : 409, retriable: false } });
6
+ }
7
+ function refused(code) {
8
+ return new ArtifactOperationError('unauthorized_at_commit', { refusal: { allowed: false,
9
+ reason: 'denied', status: code === 'gone' ? 410 : code === 'not_found' ? 404 : 403,
10
+ publicCode: code, retriable: false } });
11
+ }
12
+ /** Targeted writes and proof consumption share the existing coordinator receipt transaction. */
13
+ export async function manageArtifact(deps, invocation, command, proof) {
14
+ const artifactId = command.payload.artifactId;
15
+ const deleting = command.operation === 'delete';
16
+ let unit;
17
+ const receipt = {};
18
+ const context = { actorId: invocation.actorId, storageScopeId: invocation.storageScopeId,
19
+ artifactId, expectedCurrentReleaseId: command.payload.expectedCurrentReleaseId,
20
+ commandHash: command.identity.commandHash, internalKey: command.identity.internalKey };
21
+ const owned = async () => {
22
+ const target = await unit.read(artifactId);
23
+ if (!target || target.acl.storageScopeId !== invocation.storageScopeId)
24
+ throw refused('not_found');
25
+ if (target.acl.ownerId !== invocation.actorId)
26
+ throw refused('access_denied');
27
+ return target;
28
+ };
29
+ const request = {
30
+ ...command.identity, scopeId: invocation.storageScopeId, actorId: invocation.actorId,
31
+ invocationId: invocation.invocationId, operation: command.operation, artifactId,
32
+ policyRevision: await deps.readPolicyRevision(), receiptData: receipt,
33
+ locks: { 'artifact-lifecycle': [async (client) => {
34
+ unit = deps.createUnit(client);
35
+ await unit.lock(artifactId);
36
+ await owned();
37
+ }] },
38
+ provisional: async (client) => {
39
+ const target = await owned();
40
+ if (target.terminal || target.state === 'deleted' || target.state === 'purged')
41
+ throw refused('gone');
42
+ const now = new Date().toISOString();
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;
47
+ if (target.currentReleaseId !== context.expectedCurrentReleaseId) {
48
+ if (proof)
49
+ throw confirmationFailure('confirmation_stale');
50
+ throw new ArtifactOperationError('pointer_conflict');
51
+ }
52
+ if (!deps.confirmation)
53
+ throw new ArtifactOperationError('provider_unavailable');
54
+ if (!proof)
55
+ throw confirmationFailure('confirmation_required', await deps.confirmation.request(context));
56
+ const verdict = await deps.confirmation.consume(client, context, proof);
57
+ if (verdict !== 'valid')
58
+ throw confirmationFailure(`confirmation_${verdict}`);
59
+ await unit.delete(artifactId, now);
60
+ Object.assign(receipt, { artifactId, state: 'deleted', terminal: true, deletedAt: now, purgeState: 'pending' });
61
+ }
62
+ else {
63
+ const principalId = command.payload.principalId;
64
+ if (command.payload.action === 'grant' && !await deps.isApprovedPrincipal(principalId))
65
+ throw refused('access_denied');
66
+ const output = await unit.setAccess(artifactId, principalId, command.payload.action, now);
67
+ Object.assign(receipt, { artifactId, isPublic: false, ...output });
68
+ }
69
+ },
70
+ guard: async (ctx, tx) => {
71
+ const target = await owned();
72
+ if (!deleting && (target.terminal || target.state === 'deleted' || target.state === 'purged'))
73
+ throw refused('gone');
74
+ if (!deleting && command.payload.action === 'grant' && !await deps.isApprovedPrincipal(command.payload.principalId))
75
+ throw refused('access_denied');
76
+ return invocation.authorizeCommit(ctx, tx);
77
+ },
78
+ validateReplayReceipt: (stored) => stored.receipt?.artifactId === artifactId,
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);
84
+ return result.ok ? { ...result, result: result.result.receipt }
85
+ : { ok: false, failure: { ...result.failure, kind: 'operational' } };
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,16 +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 } };
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 } : {}) } };
5
16
  }
6
17
  /** Feed this application result to mcp-platform's existing result codec. No second wire protocol. */
7
- export function projectArtifactMcpOutcome(outcome, auditId) {
18
+ export function projectArtifactMcpOutcome(outcome, auditId, resultEnvelopeVersion = 2) {
8
19
  if (outcome.ok)
9
- return { ok: true, output: outcome.result.body, auditId, redactionClass: 'low' };
10
- 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);
11
28
  return {
12
- ok: false, auditId, redactionClass: 'low',
13
- error: { code: publicCode, message: publicCode, retriable },
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 }) },
14
32
  };
15
33
  }
16
34
  /**
@@ -3,23 +3,28 @@ 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';
7
- import type { ArtifactState } from './lifecycle.js';
6
+ import type { ArtifactAcl, ArtifactMetadata } from './access.js';
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;
16
19
  manifest: string;
17
- state: 'ready';
20
+ state: ReleaseState;
18
21
  createdAt: string;
19
22
  }
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
  }
@@ -61,14 +66,10 @@ export interface PublicationService {
61
66
  readonly limits?: Readonly<PublicationLimits>;
62
67
  /** The host source store participates in the idempotent commit when a source is supplied. */
63
68
  publish(input: PublishInput, sources?: SourceStore): Promise<PublishResult>;
64
- getCurrent(artifactId: string): Promise<{
65
- releaseId: string;
66
- manifest: string;
67
- } | undefined>;
68
- getRelease(releaseId: string): Promise<{
69
- releaseId: string;
70
- manifest: string;
71
- } | undefined>;
69
+ getCurrent(artifactId: string): Promise<ReleaseRecord | undefined>;
70
+ getRelease(releaseId: string): Promise<ReleaseRecord | undefined>;
71
+ /** Release history records (digest, manifest, createdAt) for list_versions. */
72
+ listReleases(artifactId: string): Promise<ReleaseRecord[]>;
72
73
  }
73
74
  /** Create a publication service over injected stores (no driver imports). */
74
75
  export declare function createPublicationService(deps: {
@@ -109,6 +110,12 @@ export declare function decidePublishAuthorization(input: PublishAuthorizationIn
109
110
  };
110
111
  /** Transaction-scoped provisional writers for the finalized unit. */
111
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>;
112
119
  readAuthorization(artifactId: string): Promise<{
113
120
  state: ArtifactState;
114
121
  acl: ArtifactAcl | undefined;
@@ -123,6 +130,9 @@ export interface PublicationUnitWriter {
123
130
  /** PostgreSQL unit writer over the coordinator's leased client. */
124
131
  export declare function createPostgresPublicationUnitWriter(client: FinalizeTxClient): PublicationUnitWriter;
125
132
  export interface GuardedPublishInput extends PublishInput {
133
+ readonly expectedCurrentReleaseId?: string | null;
134
+ readonly metadata?: ArtifactMetadata;
135
+ readonly operation?: 'publish' | 'revert';
126
136
  /** Frozen trusted identity supplied by the host's L1 command normalizer. */
127
137
  readonly identity: CommandIdentity;
128
138
  readonly actorId: string;
@@ -147,6 +157,7 @@ export interface GuardedPublicationDeps {
147
157
  readonly limits?: Partial<PublicationLimits>;
148
158
  }
149
159
  export interface GuardedPublicationService {
160
+ replayFinalized(input: Omit<GuardedPublishInput, 'files' | 'source' | 'profile'>): Promise<FinalizeResult | undefined>;
150
161
  publishFinalized(input: GuardedPublishInput): Promise<FinalizeResult>;
151
162
  }
152
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
  },
@@ -168,13 +175,16 @@ export function createPublicationService(deps) {
168
175
  const release = await deps.state.getRelease(releaseId);
169
176
  if (release === undefined)
170
177
  return undefined;
171
- return { releaseId: release.releaseId, manifest: release.manifest };
178
+ return release;
172
179
  },
173
180
  async getRelease(releaseId) {
174
181
  const release = await deps.state.getRelease(releaseId);
175
182
  if (release === undefined)
176
183
  return undefined;
177
- return { releaseId: release.releaseId, manifest: release.manifest };
184
+ return release;
185
+ },
186
+ async listReleases(artifactId) {
187
+ return deps.state.listReleases(artifactId);
178
188
  },
179
189
  };
180
190
  }
@@ -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
+ };