@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.6
4
+
5
+ - Integrate part 1 with the source reads, sharing, terminal deletion, masking and
6
+ summaries published in 0.2.5. Apply `0005_metadata.sql` after 0004; it adds
7
+ editorial metadata and nullable release authors/lineage without backfilling history.
8
+ - Add version history and revert as a new release on the same stable URL, preserving
9
+ current metadata and ACLs. The v2 profile uses `targetReleaseId` and paginated history.
10
+ - Add full-bundle or exactly-once text edits, metadata on ensure/publish, and
11
+ transactional release CAS, metadata, references, quota and receipts.
12
+ - Separate trusted `profileRevisionId`, `requireExpectedCurrentReleaseId` and
13
+ `resultEnvelopeVersion`. Compatible bindings keep root results; strict v2 retains
14
+ commit state, replay status and typed failures through mcp-platform 0.4.1.
15
+ - Replay historical receipts before enforcing new preconditions or preparing edits.
16
+ Missing strict preconditions use the R2 `precondition_required` message and details.
17
+
18
+ IMMO activates part 1 only after its qualified input and result switchovers.
19
+ Artifacts 0.2.6 stays inside the existing cluster-mesh `>=0.2.0 <0.3.0` range;
20
+ the API consumes the workspace package and retains its pinned APP-P1 profile.
21
+
3
22
  ## 0.2.5
4
23
 
5
24
  - D1: Read verified author manifests and file bytes for the current or a selected
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @sentropic/artifacts — generic artifact contracts (L-5a)
2
2
 
3
+ Publication profiles can set `requireExpectedCurrentReleaseId: true` to require an
4
+ explicit UUID or `null` on publish/revert. Strict profiles reject writers without
5
+ CAS. Omission remains supported in generic profiles. Replay the same caller key
6
+ after an unknown commit outcome; changing files, edits, metadata or the precondition
7
+ requires a new key. The `artifacts-outcome/v2` MCP envelope requires
8
+ `@sentropic/mcp-platform >=0.4.1` and preserves commit state and typed error details.
9
+
3
10
  ## Shared PostgreSQL activation (UP-T, 0.2.3)
4
11
 
5
12
  W1.c/W3.a and Radar S1/A6 use the same imports:
@@ -314,7 +321,8 @@ The installed package includes SQL resources exported under
314
321
  ```js
315
322
  import { readFile } from 'node:fs/promises';
316
323
 
317
- const migrations = ['0001_artifacts.sql', '0002_lifecycle.sql'];
324
+ const migrations = ['0001_artifacts.sql', '0002_lifecycle.sql', '0003_coordinator.sql',
325
+ '0004_terminal_controls.sql', '0005_metadata.sql'];
318
326
  for (const name of migrations) {
319
327
  const sql = await readFile(new URL(import.meta.resolve(
320
328
  `@sentropic/artifacts/migrations/${name}`,
@@ -324,8 +332,10 @@ for (const name of migrations) {
324
332
  ```
325
333
 
326
334
  Apply `0001_artifacts.sql` first to create the domain schema, then
327
- `0002_lifecycle.sql` to add lifecycle, sharing, sources and GC support, before
328
- using the Postgres store. The host owns migration credentials, serialization,
335
+ `0002_lifecycle.sql` to add lifecycle, sharing, sources and GC support, then
336
+ `0003_coordinator.sql`, `0004_terminal_controls.sql`, and `0005_metadata.sql`
337
+ (artifact display metadata and nullable release lineage),
338
+ before using the Postgres store. The host owns migration credentials, serialization,
329
339
  transactions and the migration journal; execute each version through that
330
340
  runner rather than running this read loop at application startup.
331
341
 
@@ -21,6 +21,9 @@ function toIntent(row) {
21
21
  }
22
22
  function toRelease(row) {
23
23
  return {
24
+ authorPrincipalId: row.author_principal_id ?? null,
25
+ parentReleaseId: row.parent_release_id ?? null,
26
+ revertedFromReleaseId: row.reverted_from_release_id ?? null,
24
27
  releaseId: row.id,
25
28
  artifactId: row.artifact_id,
26
29
  manifestDigest: row.manifest_digest,
@@ -143,11 +146,14 @@ export function createPostgresArtifactStore(pool) {
143
146
  }
144
147
  },
145
148
  async getRelease(releaseId) {
146
- const { rows } = await pool.query(`SELECT id, artifact_id, manifest_digest, manifest, state, created_at
147
- FROM artifacts.releases WHERE id = $1`, [releaseId]);
149
+ const { rows } = await pool.query(`SELECT * FROM artifacts.releases WHERE id = $1`, [releaseId]);
148
150
  const row = rows[0];
149
151
  return row === undefined ? undefined : toRelease(row);
150
152
  },
153
+ async listReleases(artifactId) {
154
+ const { rows } = await pool.query(`SELECT * FROM artifacts.releases WHERE artifact_id = $1 ORDER BY created_at, id`, [artifactId]);
155
+ return rows.map(toRelease);
156
+ },
151
157
  async getCurrentReleaseId(artifactId) {
152
158
  const { rows } = await pool.query(`SELECT release_id FROM artifacts.artifact_current WHERE artifact_id = $1`, [artifactId]);
153
159
  return rows[0]?.release_id;
@@ -209,14 +215,15 @@ export function createPostgresArtifactStore(pool) {
209
215
  principalId: grant.principal_id,
210
216
  grant: grant.permission,
211
217
  })),
218
+ ...(row.metadata === null || Object.keys(row.metadata).length === 0 ? {} : { metadata: row.metadata }),
212
219
  };
213
220
  },
214
221
  async putArtifactAcl(artifactId, acl) {
215
- await pool.query(`UPDATE artifacts.artifacts SET is_public = $2,
222
+ await pool.query(`UPDATE artifacts.artifacts SET is_public = $2, metadata = $4,
216
223
  lifecycle_state = CASE WHEN $3 THEN 'withdrawn'
217
224
  WHEN lifecycle_state = 'withdrawn' THEN 'active'
218
225
  ELSE lifecycle_state END
219
- WHERE id = $1`, [artifactId, acl.isPublic, acl.withdrawn]);
226
+ WHERE id = $1`, [artifactId, acl.isPublic, acl.withdrawn, JSON.stringify(acl.metadata ?? {})]);
220
227
  await pool.query(`DELETE FROM artifacts.artifact_access WHERE artifact_id = $1`, [
221
228
  artifactId,
222
229
  ]);
package/dist/http.d.ts CHANGED
@@ -40,7 +40,8 @@ export interface HttpAppDeps {
40
40
  ensureHost: GuardedEnsureHost;
41
41
  publication: Omit<GuardedPublicationDeps, 'coordinator' | 'limits'> & {
42
42
  getCurrent: PublicationService['getCurrent'];
43
- getRelease?: PublicationService['getRelease'];
43
+ getRelease: PublicationService['getRelease'];
44
+ listReleases: PublicationService['listReleases'];
44
45
  };
45
46
  sources?: Pick<SourceStore, 'getSource'>;
46
47
  management?: {
@@ -48,7 +49,7 @@ export interface HttpAppDeps {
48
49
  confirmation?: ArtifactConfirmationPort;
49
50
  };
50
51
  acls: AclStore;
51
- lifecycle: Pick<LifecycleService, 'stateOf' | 'canRead'>;
52
+ lifecycle: Pick<LifecycleService, 'stateOf' | 'canRead' | 'getReleaseState'>;
52
53
  isApprovedAccount?: (principalId: string) => boolean;
53
54
  /** One trusted URL policy shared by HTTP and MCP receipts. */
54
55
  publicationUrls?: (artifactId: string, releaseId: string) => {
package/dist/http.js CHANGED
@@ -1,14 +1,18 @@
1
+ /** Selected HTTP control surface over the authorized atomic services. */
2
+ import { createHash } from 'node:crypto';
1
3
  import { parseManifest } from './codecs.js';
2
4
  import { assertCompiledArtifactsProfile, ArtifactProfileInputError } from './profile/index.js';
3
5
  import { createArtifactCommand } from './server/command-identity.js';
4
6
  import { createGuardedEnsureService } from './server/service.js';
5
7
  import { assertArtifactReplayMigration } from './server/idempotency.js';
6
8
  import { createGuardedPublicationService } from './server/publication.js';
9
+ import { applyEdits } from './server/edits.js';
7
10
  import { CommitQuarantineError } from './server/commit/types.js';
8
11
  import { ArtifactOperationError, classifyArtifactFailure } from './server/failures.js';
9
12
  import { projectArtifactHttpOutcome } from './server/outcome-projection.js';
10
13
  import { readArtifactSource } from './server/source-read.js';
11
14
  import { artifactSummary } from './server/summary.js';
15
+ import { artifactVersions } from './server/versions.js';
12
16
  import { manageArtifact } from './server/management.js';
13
17
  import { createPostgresManagementUnit } from './adapters/postgres-management.js';
14
18
  export { projectArtifactHttpOutcome } from './server/outcome-projection.js';
@@ -20,6 +24,7 @@ export function errorBody(status) {
20
24
  }
21
25
  /** Retained for consumers describing canonical textual publication inputs. */
22
26
  export const TEXT_MIME_PATTERN = '^(?:text/[a-z0-9!#$&^_.+-]+|application/(?:javascript|ecmascript|json|xml|[a-z0-9!#$&^_.+-]+\\+(?:json|xml))|image/svg\\+xml)(?![\\s\\S])';
27
+ const TEXT_MIME_RE = new RegExp(TEXT_MIME_PATTERN);
23
28
  function object(value) {
24
29
  if (typeof value !== 'object' || value === null || Array.isArray(value))
25
30
  throw new ArtifactProfileInputError();
@@ -29,7 +34,7 @@ function object(value) {
29
34
  export function assertArtifactsTransportProfile(value) {
30
35
  assertCompiledArtifactsProfile(value);
31
36
  const supported = ['artifacts_ensure', 'artifacts_publish', 'artifacts_list', 'artifacts_get',
32
- 'artifacts_get_source', 'artifacts_set_access', 'artifacts_delete'];
37
+ 'artifacts_list_versions', 'artifacts_get_source', 'artifacts_revert', 'artifacts_set_access', 'artifacts_delete'];
33
38
  if (value.selectedCapabilities.some((name) => !supported.includes(name))) {
34
39
  throw new Error('unsupported artifacts transport capability');
35
40
  }
@@ -52,23 +57,138 @@ export function createHttpArtifactApp(deps) {
52
57
  throw new Error('artifact delete confirmation port required');
53
58
  }
54
59
  const ensure = createGuardedEnsureService({ profile, coordinator: deps.coordinator,
55
- derivation: deps.derivation, host: deps.ensureHost });
56
- async function visible(artifactId, invocation, allowTerminalOwner = false) {
60
+ derivation: deps.derivation, host: {
61
+ lockBinding: (client, binding) => deps.ensureHost.lockBinding(client, binding),
62
+ abandonAttempt: id => deps.ensureHost.abandonAttempt(id),
63
+ readPolicyRevision: () => deps.ensureHost.readPolicyRevision(),
64
+ async resolveBinding(client, binding) {
65
+ const receipt = await deps.ensureHost.resolveBinding(client, binding);
66
+ if (receipt.created && binding.metadata) {
67
+ await deps.publication.createUnitWriter(client).updateMetadata(receipt.artifactId, binding.metadata);
68
+ }
69
+ return receipt;
70
+ },
71
+ } });
72
+ async function visible(artifactId, invocation, action = 'read') {
57
73
  const acl = await deps.acls.getArtifactAcl(artifactId);
58
74
  if (!acl || acl.storageScopeId !== invocation.storageScopeId)
59
75
  throw new ArtifactOperationError('not_found');
60
76
  const state = await deps.lifecycle.stateOf(artifactId);
61
77
  if (acl.ownerId === invocation.actorId && (state === 'deleted' || state === 'purged')) {
62
78
  // Management must still authorize terminal owners before replaying a receipt.
63
- if (allowTerminalOwner)
79
+ if (action === true)
64
80
  return acl;
65
81
  throw new ArtifactOperationError('gone');
66
82
  }
67
- if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'read', deps.isApprovedAccount)) {
83
+ if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, action === true ? 'read' : action, deps.isApprovedAccount)) {
68
84
  throw new ArtifactOperationError('not_found');
69
85
  }
70
86
  return acl;
71
87
  }
88
+ /** Display metadata fields present on validated args (ensure/publish). */
89
+ function metadataOf(normalized) {
90
+ const meta = {};
91
+ if (typeof normalized.title === 'string')
92
+ meta.title = normalized.title;
93
+ if (typeof normalized.description === 'string')
94
+ meta.description = normalized.description;
95
+ if (typeof normalized.icon === 'string' || normalized.icon === null)
96
+ meta.icon = normalized.icon;
97
+ return Object.keys(meta).length > 0 ? meta : undefined;
98
+ }
99
+ function blobKey(storageScopeId, digest) {
100
+ // Manifest digests are `sha256:<hex>`; physical keys are `.../blobs/sha256/<hex>`.
101
+ return `artifacts/${storageScopeId}/blobs/${digest.replace(':', '/')}`;
102
+ }
103
+ async function readManifestFiles(manifest, storageScopeId) {
104
+ const parsed = parseManifest(manifest);
105
+ const entries = parsed.files ?? [];
106
+ const files = [];
107
+ for (const entry of entries) {
108
+ const bytes = await deps.publication.blobs.getBlob(blobKey(storageScopeId, entry.digest));
109
+ if (bytes === undefined)
110
+ throw new ArtifactOperationError('release_unavailable', { release: { reason: 'not_retained' } });
111
+ if (bytes.byteLength !== entry.size || `sha256:${createHash('sha256').update(bytes).digest('hex')}` !== entry.digest) {
112
+ throw new ArtifactOperationError('verification_failed');
113
+ }
114
+ let text;
115
+ if (TEXT_MIME_RE.test(entry.mimeType)) {
116
+ try {
117
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
118
+ }
119
+ catch { /* Non-UTF-8 is not editable. */ }
120
+ }
121
+ files.push({ path: entry.path, mimeType: entry.mimeType, bytes: new Uint8Array(bytes),
122
+ ...(text === undefined ? {} : { text }) });
123
+ }
124
+ return files;
125
+ }
126
+ /** Apply publish edits to the current release's text files; typed edit_mismatch changes nothing. */
127
+ async function editedFiles(artifactId, invocation, edits, expected) {
128
+ const current = expected === undefined ? await deps.publication.getCurrent(artifactId)
129
+ : expected === null ? undefined : await deps.publication.getRelease(expected);
130
+ if (current === undefined) {
131
+ if (expected === null || expected === undefined)
132
+ throw new ArtifactOperationError('release_unavailable', { release: { reason: 'unpublished' } });
133
+ throw new ArtifactOperationError('not_found');
134
+ }
135
+ if (parseManifest(current.manifest).artifactId !== artifactId)
136
+ throw new ArtifactOperationError('not_found');
137
+ if (await deps.lifecycle.getReleaseState(current.releaseId) === 'purged') {
138
+ throw new ArtifactOperationError('release_unavailable', { release: { reason: 'purged' } });
139
+ }
140
+ const files = await readManifestFiles(current.manifest, invocation.storageScopeId);
141
+ return applyEdits(files, edits).map((file) => ({
142
+ path: file.path, mimeType: file.mimeType,
143
+ bytes: file.text === undefined ? file.bytes : new Uint8Array(Buffer.from(file.text, 'utf8')),
144
+ }));
145
+ }
146
+ async function conflictSource(outcome, normalized, invocation) {
147
+ if (outcome.ok || outcome.failure.kind !== 'operational' || outcome.failure.variant !== 'pointer_conflict')
148
+ return outcome;
149
+ const artifactId = normalized.artifactId;
150
+ await visible(artifactId, invocation, 'source');
151
+ const conflict = outcome.failure.conflict;
152
+ if (!conflict?.currentReleaseId || normalized.edits === undefined)
153
+ return outcome;
154
+ const release = await deps.publication.getRelease(conflict.currentReleaseId);
155
+ if (!release || await deps.lifecycle.getReleaseState(release.releaseId) === 'purged')
156
+ return outcome;
157
+ const manifest = parseManifest(release.manifest);
158
+ const paths = new Set(normalized.edits.map(edit => edit.path));
159
+ const entries = manifest.files;
160
+ const files = (await readManifestFiles(release.manifest, invocation.storageScopeId)).filter(file => paths.has(file.path))
161
+ .map(file => ({ path: file.path, mimeType: file.mimeType, size: file.bytes.byteLength,
162
+ digest: entries.find(entry => entry.path === file.path).digest,
163
+ ...(file.text === undefined ? { bytes: Array.from(file.bytes) } : { text: file.text }) }));
164
+ await visible(artifactId, invocation, 'source');
165
+ const failure = { ...outcome.failure, conflict: { ...conflict, currentSource: {
166
+ artifactId, releaseId: release.releaseId, digest: conflict.currentDigest, manifest: release.manifest,
167
+ files, sourceRef: null, ...deps.publicationUrls?.(artifactId, release.releaseId),
168
+ } } };
169
+ Object.defineProperty(failure, 'cause', { value: outcome.failure.cause, enumerable: false });
170
+ return { ok: false, failure: Object.freeze(failure) };
171
+ }
172
+ /** Shared guarded-commit response for publish and revert (same commit path, same receipt shape). */
173
+ function commitOutcome(outcome, artifactId, meta, setCommitState) {
174
+ if (!outcome.ok) {
175
+ const failure = { ...outcome.failure, kind: 'operational' };
176
+ Object.defineProperty(failure, 'cause', { value: outcome.failure.cause, enumerable: false });
177
+ return { ok: false, failure: Object.freeze(failure) };
178
+ }
179
+ setCommitState(outcome.commitState);
180
+ const receipt = object(outcome.result.receipt);
181
+ if (typeof receipt.releaseId !== 'string' || typeof receipt.manifest !== 'string') {
182
+ throw new Error('invalid publication receipt');
183
+ }
184
+ return { ...outcome, result: { status: 201, body: {
185
+ artifactId, releaseId: receipt.releaseId, manifest: receipt.manifest,
186
+ previousReleaseId: receipt.previousReleaseId ?? null, updatedAt: receipt.updatedAt ?? null,
187
+ ...(typeof receipt.revertedFromReleaseId === 'string' ? { revertedFromReleaseId: receipt.revertedFromReleaseId } : {}),
188
+ digest: parseManifest(receipt.manifest).digest,
189
+ ...deps.publicationUrls?.(artifactId, receipt.releaseId), ...(receipt.metadata ?? meta ?? {}),
190
+ } } };
191
+ }
72
192
  const app = {
73
193
  selectedCapabilities: profile.selectedCapabilities,
74
194
  profileDigest: profile.profileDigest,
@@ -96,10 +216,13 @@ export function createHttpArtifactApp(deps) {
96
216
  throw new ArtifactProfileInputError();
97
217
  }
98
218
  let args;
99
- if (capability === 'artifacts_get') {
219
+ const getPrefix = request.method !== 'GET' ? undefined : capability === 'artifacts_get' ? '/meta/'
220
+ : capability === 'artifacts_list_versions' ? '/versions/'
221
+ : capability === 'artifacts_get_source' ? '/source/' : undefined;
222
+ if (getPrefix !== undefined) {
100
223
  if (request.body !== undefined && Object.keys(object(request.body)).length)
101
224
  throw new ArtifactProfileInputError();
102
- args = { artifactId: request.path.slice('/meta/'.length) };
225
+ args = { artifactId: request.path.slice(getPrefix.length) };
103
226
  }
104
227
  else {
105
228
  args = { ...object(request.body ?? {}) };
@@ -109,9 +232,9 @@ export function createHttpArtifactApp(deps) {
109
232
  delete args.storageScopeId;
110
233
  }
111
234
  }
112
- const normalized = profile.validate(capability, args, request.idempotencyKey);
113
- const command = ['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete'].includes(capability)
114
- ? createArtifactCommand(profile, capability, args, invocation, deps.derivation, request.idempotencyKey) : undefined;
235
+ const normalized = profile.validate(capability, args, request.idempotencyKey, { deferPrecondition: true });
236
+ const command = ['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete', 'artifacts_revert'].includes(capability)
237
+ ? createArtifactCommand(profile, capability, args, invocation, deps.derivation, request.idempotencyKey, { deferPrecondition: true }) : undefined;
115
238
  if (command && ['ensure', 'publish'].includes(command.operation)) {
116
239
  phase = 'migration';
117
240
  await assertArtifactReplayMigration(deps.legacyRecords, command, invocation, normalized.idempotencyKey);
@@ -121,11 +244,20 @@ export function createHttpArtifactApp(deps) {
121
244
  const outcome = await ensure.ensure(invocation, { ...normalized,
122
245
  envelopeKey: request.idempotencyKey });
123
246
  if (outcome.ok) {
247
+ commitState = outcome.commitState;
124
248
  const state = await deps.lifecycle.stateOf(outcome.result.artifactId);
125
249
  if (state === 'deleted' || state === 'purged')
126
250
  throw new ArtifactOperationError('gone');
127
251
  }
128
- return outcome.ok ? { ...outcome, result: { status: 201, body: outcome.result } } : outcome;
252
+ if (!outcome.ok)
253
+ return outcome;
254
+ const artifactId = outcome.result.artifactId;
255
+ const acl = await visible(artifactId, invocation);
256
+ const summary = artifactSummary({ artifactId, acl, actorId: invocation.actorId,
257
+ state: await deps.lifecycle.stateOf(artifactId), current: await deps.publication.getCurrent(artifactId),
258
+ urls: deps.publicationUrls, approved: deps.isApprovedAccount });
259
+ return { ...outcome, result: { status: 201, body: profile.resultEnvelopeVersion === 2
260
+ ? { ...summary, ...outcome.result } : { ...outcome.result, ...acl.metadata } } };
129
261
  }
130
262
  if (capability === 'artifacts_get_source') {
131
263
  const artifactId = normalized.artifactId;
@@ -145,15 +277,18 @@ export function createHttpArtifactApp(deps) {
145
277
  commitState: 'not_committed', replayed: false };
146
278
  }
147
279
  if (capability === 'artifacts_set_access' || capability === 'artifacts_delete') {
148
- await visible(normalized.artifactId, invocation, true);
280
+ const acl = await visible(normalized.artifactId, invocation, true);
281
+ if (acl.ownerId !== invocation.actorId)
282
+ throw new ArtifactOperationError('access_denied');
149
283
  const outcome = await manageArtifact({ coordinator: deps.coordinator,
150
284
  createUnit: deps.management?.createUnit ?? createPostgresManagementUnit,
151
285
  confirmation: deps.management?.confirmation, readPolicyRevision: () => deps.ensureHost.readPolicyRevision(),
152
286
  isApprovedPrincipal: deps.isApprovedAccount ?? (() => false),
287
+ validateNewCommand: () => { profile.validate(capability, args, request.idempotencyKey); },
153
288
  }, invocation, command, normalized.confirmationRef);
154
289
  return outcome.ok ? { ...outcome, result: { status: 200, body: outcome.result } } : outcome;
155
290
  }
156
- if (capability === 'artifacts_publish') {
291
+ if (capability === 'artifacts_publish' || capability === 'artifacts_revert') {
157
292
  const artifactId = normalized.artifactId;
158
293
  const acl = await visible(artifactId, invocation);
159
294
  if (acl.ownerId !== invocation.actorId)
@@ -162,31 +297,53 @@ export function createHttpArtifactApp(deps) {
162
297
  const publication = createGuardedPublicationService({ ...deps.publication, coordinator: deps.coordinator,
163
298
  limits: { maxFiles: limits.publishFilesMax, maxFileBytes: limits.maxDecodedFileBytes,
164
299
  maxTotalBytes: limits.maxDecodedTotalBytes } });
165
- const outcome = await publication.publishFinalized({
166
- ownerId: acl.ownerId, storageScopeId: invocation.storageScopeId, artifactId,
300
+ const meta = metadataOf(normalized);
301
+ const input = { ownerId: acl.ownerId, storageScopeId: invocation.storageScopeId, artifactId,
167
302
  identity: command.identity, actorId: invocation.actorId, invocationId: invocation.invocationId,
168
303
  attemptId: invocation.invocationId, policyRevision: await deps.ensureHost.readPolicyRevision(),
169
- authorizeCommit: invocation.authorizeCommit,
170
- source: { kind: 'inline-original' }, profile: normalized.profile,
171
- files: normalized.files.map((file) => ({
172
- path: file.path, mimeType: file.mimeType, bytes: Uint8Array.from(file.bytes),
173
- })),
174
- });
175
- if (!outcome.ok) {
176
- const failure = { ...outcome.failure, kind: 'operational' };
177
- Object.defineProperty(failure, 'cause', { value: outcome.failure.cause, enumerable: false });
178
- return { ok: false, failure: Object.freeze(failure) };
304
+ authorizeCommit: invocation.authorizeCommit, metadata: meta,
305
+ operation: capability === 'artifacts_revert' ? 'revert' : 'publish',
306
+ expectedCurrentReleaseId: normalized.expectedCurrentReleaseId };
307
+ const replay = await publication.replayFinalized(input);
308
+ if (replay !== undefined)
309
+ return commitOutcome(replay, artifactId, meta, state => { commitState = state; });
310
+ profile.validate(capability, args, request.idempotencyKey);
311
+ let files;
312
+ let source;
313
+ let releaseProfile;
314
+ if (capability === 'artifacts_publish') {
315
+ files = normalized.edits !== undefined
316
+ ? await editedFiles(artifactId, invocation, normalized.edits, input.expectedCurrentReleaseId)
317
+ : normalized.files.map((file) => ({
318
+ path: file.path, mimeType: file.mimeType, bytes: Uint8Array.from(file.bytes),
319
+ }));
320
+ if (normalized.edits !== undefined) {
321
+ const { edits: _edits, ...snapshot } = normalized;
322
+ profile.validate(capability, { ...snapshot, files: files.map(file => ({ ...file, bytes: Array.from(file.bytes) })) });
323
+ }
324
+ source = { kind: 'inline-original' };
325
+ releaseProfile = normalized.profile;
179
326
  }
180
- commitState = outcome.commitState;
181
- const receipt = object(outcome.result.receipt);
182
- if (typeof receipt.releaseId !== 'string' || typeof receipt.manifest !== 'string') {
183
- throw new Error('invalid publication receipt');
327
+ else {
328
+ const releaseId = normalized[profile.resultEnvelopeVersion === 2 ? 'targetReleaseId' : 'releaseId'];
329
+ const target = await deps.publication.getRelease(releaseId);
330
+ const targetManifest = target === undefined ? undefined : parseManifest(target.manifest);
331
+ if (target === undefined || targetManifest?.artifactId !== artifactId) {
332
+ throw new ArtifactOperationError('not_found');
333
+ }
334
+ if (await deps.lifecycle.getReleaseState(releaseId) === 'purged') {
335
+ throw new ArtifactOperationError('release_unavailable', { release: { reason: 'purged' } });
336
+ }
337
+ files = (await readManifestFiles(target.manifest, invocation.storageScopeId))
338
+ .map((file) => ({ path: file.path, mimeType: file.mimeType, bytes: file.bytes }));
339
+ source = { kind: 'base-commit', commit: releaseId };
340
+ releaseProfile = (targetManifest.profile ?? {});
184
341
  }
185
- return { ...outcome, result: { status: 201, body: {
186
- artifactId, releaseId: receipt.releaseId, manifest: receipt.manifest,
187
- digest: parseManifest(receipt.manifest).digest,
188
- ...deps.publicationUrls?.(artifactId, receipt.releaseId),
189
- } } };
342
+ const outcome = await publication.publishFinalized({
343
+ ...input, source, profile: releaseProfile, files,
344
+ });
345
+ const committed = commitOutcome(outcome, artifactId, meta, (state) => { commitState = state; });
346
+ return await conflictSource(committed, normalized, invocation);
190
347
  }
191
348
  if (capability === 'artifacts_get') {
192
349
  const artifactId = normalized.artifactId;
@@ -196,8 +353,34 @@ export function createHttpArtifactApp(deps) {
196
353
  return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
197
354
  result: { status: 200, body: { ...artifactSummary({ artifactId, acl, state, current,
198
355
  actorId: invocation.actorId, urls: deps.publicationUrls, approved: deps.isApprovedAccount }),
356
+ ...(acl.metadata !== undefined ? { metadata: acl.metadata } : {}),
199
357
  ...(acl.ownerId === invocation.actorId ? { grants: acl.grants } : {}) } } };
200
358
  }
359
+ if (capability === 'artifacts_list_versions') {
360
+ const artifactId = normalized.artifactId;
361
+ await visible(artifactId, invocation);
362
+ const current = await deps.publication.getCurrent(artifactId);
363
+ if (profile.resultEnvelopeVersion === 2) {
364
+ const body = await artifactVersions({ artifactId, storageScopeId: invocation.storageScopeId,
365
+ currentReleaseId: current?.releaseId ?? null, releases: await deps.publication.listReleases(artifactId),
366
+ limit: normalized.limit, cursor: normalized.cursor,
367
+ state: id => deps.lifecycle.getReleaseState(id), urls: deps.publicationUrls });
368
+ await visible(artifactId, invocation);
369
+ return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
370
+ result: { status: 200, body } };
371
+ }
372
+ const releases = [];
373
+ for (const release of await deps.publication.listReleases(artifactId)) {
374
+ const manifest = parseManifest(release.manifest);
375
+ releases.push({ releaseId: release.releaseId, createdAt: release.createdAt,
376
+ author: typeof manifest.ownerId === 'string' ? manifest.ownerId : null,
377
+ state: await deps.lifecycle.getReleaseState(release.releaseId),
378
+ isCurrent: current?.releaseId === release.releaseId,
379
+ digest: release.manifestDigest });
380
+ }
381
+ return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
382
+ result: { status: 200, body: { artifactId, total: releases.length, releases } } };
383
+ }
201
384
  const visibleArtifacts = [];
202
385
  for (const artifactId of await deps.acls.listArtifactIds()) {
203
386
  const acl = await deps.acls.getArtifactAcl(artifactId);
@@ -217,7 +400,7 @@ export function createHttpArtifactApp(deps) {
217
400
  catch (cause) {
218
401
  const failure = classifyArtifactFailure(cause, { invocationId: invocation?.invocationId ?? '',
219
402
  operation, phase, commitState }, (error) => error instanceof ArtifactProfileInputError
220
- ? new ArtifactOperationError(error.category) : deps.mapHostError?.(error));
403
+ ? new ArtifactOperationError(error.category, { publicDetails: error.details }) : deps.mapHostError?.(error));
221
404
  if (cause instanceof CommitQuarantineError) {
222
405
  const migration = { ...failure, kind: 'operational', category: 'provider_unavailable',
223
406
  variant: 'legacy_quarantine', httpStatus: 503, publicCode: 'capability_unavailable', retriable: false };
@@ -227,7 +410,9 @@ export function createHttpArtifactApp(deps) {
227
410
  return { ok: false, failure };
228
411
  }
229
412
  },
230
- async handle(request, invocation) { return projectArtifactHttpOutcome(await app.execute(request, invocation)); },
413
+ async handle(request, invocation) {
414
+ return projectArtifactHttpOutcome(await app.execute(request, invocation), profile.resultEnvelopeVersion === 2);
415
+ },
231
416
  };
232
417
  return Object.freeze(app);
233
418
  }
@@ -10,11 +10,11 @@ export { createRadarKeyDerivation } from './server/command-identity.js';
10
10
  export type { ArtifactKeyDerivationBinding } from './server/command-identity.js';
11
11
  export declare const ARTIFACTS_PROVIDER_ID = "sentropic.artifacts";
12
12
  export declare const ARTIFACTS_APP_ID = "sentropic-artifacts";
13
- export declare const ARTIFACTS_PROVIDER_VERSION = "0.2.5";
14
- export declare const ARTIFACTS_TOOL_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"];
13
+ export declare const ARTIFACTS_PROVIDER_VERSION = "0.2.6";
14
+ export declare const ARTIFACTS_TOOL_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"];
15
15
  export type ArtifactsToolName = (typeof ARTIFACTS_TOOL_NAMES)[number];
16
16
  export declare const ARTIFACTS_TOOL_SPECS: {
17
- name: "artifacts_ensure" | "artifacts_publish" | "artifacts_set_access" | "artifacts_delete" | "artifacts_get_template" | "artifacts_get_api_guide" | "artifacts_create_upload" | "artifacts_commit_upload" | "artifacts_list" | "artifacts_get" | "artifacts_get_source" | "artifacts_fork" | "artifacts_promote" | "artifacts_renew" | "artifacts_restore";
17
+ name: "artifacts_ensure" | "artifacts_publish" | "artifacts_set_access" | "artifacts_delete" | "artifacts_revert" | "artifacts_get_template" | "artifacts_get_api_guide" | "artifacts_create_upload" | "artifacts_commit_upload" | "artifacts_list" | "artifacts_get" | "artifacts_get_source" | "artifacts_fork" | "artifacts_promote" | "artifacts_renew" | "artifacts_restore" | "artifacts_list_versions";
18
18
  description: string;
19
19
  mutability: import("@sentropic/mcp-platform").Mutability;
20
20
  }[];
package/dist/mcp-core.js CHANGED
@@ -2,16 +2,17 @@ import { assertArtifactsTransportProfile } from './http.js';
2
2
  import { ARTIFACT_CAPABILITY_NAMES, ArtifactProfileInputError } from './profile/index.js';
3
3
  import { ArtifactOperationError, classifyArtifactFailure } from './server/failures.js';
4
4
  import { projectArtifactHttpOutcome, projectArtifactMcpOutcome } from './server/outcome-projection.js';
5
+ import { ARTIFACT_RESULT_SCHEMA } from './server/result-schema.js';
5
6
  export * from './profile/index.js';
6
7
  export { createRadarKeyDerivation } from './server/command-identity.js';
7
8
  export const ARTIFACTS_PROVIDER_ID = 'sentropic.artifacts';
8
9
  export const ARTIFACTS_APP_ID = 'sentropic-artifacts';
9
- export const ARTIFACTS_PROVIDER_VERSION = '0.2.5';
10
+ export const ARTIFACTS_PROVIDER_VERSION = '0.2.6';
10
11
  export const ARTIFACTS_TOOL_NAMES = ARTIFACT_CAPABILITY_NAMES;
11
12
  export const ARTIFACTS_TOOL_SPECS = ARTIFACTS_TOOL_NAMES.map((name) => ({ name,
12
13
  description: `Artifacts ${name.slice('artifacts_'.length).replaceAll('_', ' ')}.`,
13
14
  mutability: (['artifacts_list', 'artifacts_get', 'artifacts_get_template', 'artifacts_get_api_guide',
14
- 'artifacts_get_source'].includes(name) ? 'read-only' : name === 'artifacts_publish' ? 'append' : 'state-transition'),
15
+ 'artifacts_get_source', 'artifacts_list_versions'].includes(name) ? 'read-only' : name === 'artifacts_publish' ? 'append' : 'state-transition'),
15
16
  }));
16
17
  export const ARTIFACTS_PARAMETER_SCHEMA = { properties: {}, required: [] };
17
18
  export function createArtifactsManifest(options) {
@@ -24,8 +25,10 @@ export function createArtifactsManifest(options) {
24
25
  resources: [], prompts: [],
25
26
  tools: ARTIFACTS_TOOL_SPECS.filter((spec) => profile.selectedCapabilities.includes(spec.name)).map((spec) => ({
26
27
  kind: 'tool', name: spec.name,
27
- description: spec.description,
28
- inputSchema: profile.schemas[spec.name], outputSchema: { type: 'object' },
28
+ description: spec.description + (profile.profileRevisionId
29
+ ? ` Active profile: ${profile.profileRevisionId}; resultEnvelopeVersion: ${profile.resultEnvelopeVersion}.` : ''),
30
+ inputSchema: profile.schemas[spec.name], outputSchema: profile.resultEnvelopeVersion === 2
31
+ ? ARTIFACT_RESULT_SCHEMA : { type: 'object', additionalProperties: true },
29
32
  requiredScopes: [...scopes], requiredClaims: [], redactionClass: 'low',
30
33
  mutability: spec.mutability, mutatesExternalSystem: spec.mutability !== 'read-only',
31
34
  idempotency: profile.idempotency[spec.name] ?? { required: false },
@@ -38,7 +41,8 @@ export function createArtifactsManifest(options) {
38
41
  }
39
42
  function rejected(cause, invocationId, operation, mapHostError) {
40
43
  return { ok: false, failure: classifyArtifactFailure(cause, { invocationId, operation,
41
- phase: cause instanceof ArtifactProfileInputError ? cause.phase : 'entry', commitState: 'not_committed' }, (error) => error instanceof ArtifactProfileInputError ? new ArtifactOperationError(error.category) : mapHostError?.(error)) };
44
+ phase: cause instanceof ArtifactProfileInputError ? cause.phase : 'entry', commitState: 'not_committed' }, (error) => error instanceof ArtifactProfileInputError
45
+ ? new ArtifactOperationError(error.category, { publicDetails: error.details }) : mapHostError?.(error)) };
42
46
  }
43
47
  export function createArtifactsToolExecutor(deps) {
44
48
  assertArtifactsTransportProfile(deps.profile);
@@ -49,7 +53,7 @@ export function createArtifactsToolExecutor(deps) {
49
53
  }
50
54
  async function execute(input) {
51
55
  try {
52
- const args = profile.validate(input.tool, input.args, input.idempotencyKey);
56
+ const args = profile.validate(input.tool, input.args, input.idempotencyKey, { deferPrecondition: true });
53
57
  let request;
54
58
  switch (input.tool) {
55
59
  case 'artifacts_ensure':
@@ -73,6 +77,12 @@ export function createArtifactsToolExecutor(deps) {
73
77
  case 'artifacts_delete':
74
78
  request = { method: 'POST', path: '/delete', body: args };
75
79
  break;
80
+ case 'artifacts_list_versions':
81
+ request = { method: 'POST', path: '/list_versions', body: args };
82
+ break;
83
+ case 'artifacts_revert':
84
+ request = { method: 'POST', path: '/revert', body: args };
85
+ break;
76
86
  default: throw new ArtifactProfileInputError(true);
77
87
  }
78
88
  return await deps.app.execute({ ...request, actorId: input.invocation.actorId,
@@ -83,7 +93,7 @@ export function createArtifactsToolExecutor(deps) {
83
93
  }
84
94
  }
85
95
  return { execute, async invoke(input) {
86
- return projectArtifactHttpOutcome(await execute(input));
96
+ return projectArtifactHttpOutcome(await execute(input), profile.resultEnvelopeVersion === 2);
87
97
  } };
88
98
  }
89
99
  export function createArtifactsAdapter(options) {
@@ -101,7 +111,7 @@ export function createArtifactsAdapter(options) {
101
111
  let outcome;
102
112
  try {
103
113
  // Selection and closed input checks precede even the host invocation binding.
104
- options.profile.validate(request.capabilityRef, request.input, request.idempotencyKey);
114
+ options.profile.validate(request.capabilityRef, request.input, request.idempotencyKey, { deferPrecondition: true });
105
115
  const invocation = await options.createInvocation(request);
106
116
  if (invocation.actorId !== request.ctx.principal.sub)
107
117
  throw new ArtifactProfileInputError();
@@ -111,7 +121,7 @@ export function createArtifactsAdapter(options) {
111
121
  catch (cause) {
112
122
  outcome = rejected(cause, request.ctx.requestId, request.capabilityRef, options.mapHostError);
113
123
  }
114
- return projectArtifactMcpOutcome(outcome, request.ctx.auditId);
124
+ return projectArtifactMcpOutcome(outcome, request.ctx.auditId, options.profile.resultEnvelopeVersion ?? 1);
115
125
  },
116
126
  readResource: async (request) => ({ ok: false, auditId: request.ctx.auditId, redactionClass: 'low',
117
127
  error: { code: 'not_found', message: 'not_found', retriable: false } }),