@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
package/dist/http.js CHANGED
@@ -1,12 +1,20 @@
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';
13
+ import { readArtifactSource } from './server/source-read.js';
14
+ import { artifactSummary } from './server/summary.js';
15
+ import { artifactVersions } from './server/versions.js';
16
+ import { manageArtifact } from './server/management.js';
17
+ import { createPostgresManagementUnit } from './adapters/postgres-management.js';
10
18
  export { projectArtifactHttpOutcome } from './server/outcome-projection.js';
11
19
  /** Stable content-route error codes; private driver messages never leave the host. */
12
20
  export function errorBody(status) {
@@ -16,6 +24,7 @@ export function errorBody(status) {
16
24
  }
17
25
  /** Retained for consumers describing canonical textual publication inputs. */
18
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);
19
28
  function object(value) {
20
29
  if (typeof value !== 'object' || value === null || Array.isArray(value))
21
30
  throw new ArtifactProfileInputError();
@@ -24,7 +33,8 @@ function object(value) {
24
33
  /** Other mutation surfaces need their own qualification before host activation. */
25
34
  export function assertArtifactsTransportProfile(value) {
26
35
  assertCompiledArtifactsProfile(value);
27
- const supported = ['artifacts_ensure', 'artifacts_publish', 'artifacts_list', 'artifacts_get'];
36
+ const supported = ['artifacts_ensure', 'artifacts_publish', 'artifacts_list', 'artifacts_get',
37
+ 'artifacts_list_versions', 'artifacts_get_source', 'artifacts_revert', 'artifacts_set_access', 'artifacts_delete'];
28
38
  if (value.selectedCapabilities.some((name) => !supported.includes(name))) {
29
39
  throw new Error('unsupported artifacts transport capability');
30
40
  }
@@ -40,18 +50,145 @@ export function createHttpArtifactApp(deps) {
40
50
  if (typeof deps.legacyRecords?.get !== 'function')
41
51
  throw new Error('artifacts requires a legacy record inventory');
42
52
  const profile = deps.profile;
53
+ if (profile.selectedCapabilities.includes('artifacts_get_source') &&
54
+ (!deps.sources || !deps.publication.getRelease))
55
+ throw new Error('artifact source read ports required');
56
+ if (profile.selectedCapabilities.includes('artifacts_delete') && !deps.management?.confirmation) {
57
+ throw new Error('artifact delete confirmation port required');
58
+ }
43
59
  const ensure = createGuardedEnsureService({ profile, coordinator: deps.coordinator,
44
- derivation: deps.derivation, host: deps.ensureHost });
45
- async function visible(artifactId, invocation) {
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') {
46
73
  const acl = await deps.acls.getArtifactAcl(artifactId);
47
74
  if (!acl || acl.storageScopeId !== invocation.storageScopeId)
48
75
  throw new ArtifactOperationError('not_found');
49
76
  const state = await deps.lifecycle.stateOf(artifactId);
50
- if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'read', deps.isApprovedAccount)) {
77
+ if (acl.ownerId === invocation.actorId && (state === 'deleted' || state === 'purged')) {
78
+ // Management must still authorize terminal owners before replaying a receipt.
79
+ if (action === true)
80
+ return acl;
81
+ throw new ArtifactOperationError('gone');
82
+ }
83
+ if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, action === true ? 'read' : action, deps.isApprovedAccount)) {
51
84
  throw new ArtifactOperationError('not_found');
52
85
  }
53
86
  return acl;
54
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
+ }
55
192
  const app = {
56
193
  selectedCapabilities: profile.selectedCapabilities,
57
194
  profileDigest: profile.profileDigest,
@@ -79,10 +216,13 @@ export function createHttpArtifactApp(deps) {
79
216
  throw new ArtifactProfileInputError();
80
217
  }
81
218
  let args;
82
- 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) {
83
223
  if (request.body !== undefined && Object.keys(object(request.body)).length)
84
224
  throw new ArtifactProfileInputError();
85
- args = { artifactId: request.path.slice('/meta/'.length) };
225
+ args = { artifactId: request.path.slice(getPrefix.length) };
86
226
  }
87
227
  else {
88
228
  args = { ...object(request.body ?? {}) };
@@ -92,10 +232,10 @@ export function createHttpArtifactApp(deps) {
92
232
  delete args.storageScopeId;
93
233
  }
94
234
  }
95
- const normalized = profile.validate(capability, args, request.idempotencyKey);
96
- const command = capability === 'artifacts_ensure' || capability === 'artifacts_publish'
97
- ? createArtifactCommand(profile, capability, args, invocation, deps.derivation, request.idempotencyKey) : undefined;
98
- if (command) {
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;
238
+ if (command && ['ensure', 'publish'].includes(command.operation)) {
99
239
  phase = 'migration';
100
240
  await assertArtifactReplayMigration(deps.legacyRecords, command, invocation, normalized.idempotencyKey);
101
241
  }
@@ -103,9 +243,52 @@ export function createHttpArtifactApp(deps) {
103
243
  if (capability === 'artifacts_ensure') {
104
244
  const outcome = await ensure.ensure(invocation, { ...normalized,
105
245
  envelopeKey: request.idempotencyKey });
106
- return outcome.ok ? { ...outcome, result: { status: 201, body: outcome.result } } : outcome;
246
+ if (outcome.ok) {
247
+ commitState = outcome.commitState;
248
+ const state = await deps.lifecycle.stateOf(outcome.result.artifactId);
249
+ if (state === 'deleted' || state === 'purged')
250
+ throw new ArtifactOperationError('gone');
251
+ }
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 } } };
261
+ }
262
+ if (capability === 'artifacts_get_source') {
263
+ const artifactId = normalized.artifactId;
264
+ const result = await readArtifactSource({
265
+ storageScopeId: invocation.storageScopeId,
266
+ authorize: async () => {
267
+ const acl = await visible(artifactId, invocation);
268
+ const state = await deps.lifecycle.stateOf(artifactId);
269
+ if (!deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'source', deps.isApprovedAccount)) {
270
+ throw new ArtifactOperationError('access_denied');
271
+ }
272
+ }, getCurrent: deps.publication.getCurrent, getRelease: deps.publication.getRelease,
273
+ getSource: (id) => deps.sources.getSource(id), getBlob: (key) => deps.publication.blobs.getBlob(key),
274
+ urls: deps.publicationUrls, maxFiles: 32, maxTotalBytes: 262144,
275
+ }, normalized);
276
+ return { ok: true, result: { status: 200, body: result }, invocationId: invocation.invocationId,
277
+ commitState: 'not_committed', replayed: false };
107
278
  }
108
- if (capability === 'artifacts_publish') {
279
+ if (capability === 'artifacts_set_access' || capability === 'artifacts_delete') {
280
+ const acl = await visible(normalized.artifactId, invocation, true);
281
+ if (acl.ownerId !== invocation.actorId)
282
+ throw new ArtifactOperationError('access_denied');
283
+ const outcome = await manageArtifact({ coordinator: deps.coordinator,
284
+ createUnit: deps.management?.createUnit ?? createPostgresManagementUnit,
285
+ confirmation: deps.management?.confirmation, readPolicyRevision: () => deps.ensureHost.readPolicyRevision(),
286
+ isApprovedPrincipal: deps.isApprovedAccount ?? (() => false),
287
+ validateNewCommand: () => { profile.validate(capability, args, request.idempotencyKey); },
288
+ }, invocation, command, normalized.confirmationRef);
289
+ return outcome.ok ? { ...outcome, result: { status: 200, body: outcome.result } } : outcome;
290
+ }
291
+ if (capability === 'artifacts_publish' || capability === 'artifacts_revert') {
109
292
  const artifactId = normalized.artifactId;
110
293
  const acl = await visible(artifactId, invocation);
111
294
  if (acl.ownerId !== invocation.actorId)
@@ -114,31 +297,53 @@ export function createHttpArtifactApp(deps) {
114
297
  const publication = createGuardedPublicationService({ ...deps.publication, coordinator: deps.coordinator,
115
298
  limits: { maxFiles: limits.publishFilesMax, maxFileBytes: limits.maxDecodedFileBytes,
116
299
  maxTotalBytes: limits.maxDecodedTotalBytes } });
117
- const outcome = await publication.publishFinalized({
118
- ownerId: acl.ownerId, storageScopeId: invocation.storageScopeId, artifactId,
300
+ const meta = metadataOf(normalized);
301
+ const input = { ownerId: acl.ownerId, storageScopeId: invocation.storageScopeId, artifactId,
119
302
  identity: command.identity, actorId: invocation.actorId, invocationId: invocation.invocationId,
120
303
  attemptId: invocation.invocationId, policyRevision: await deps.ensureHost.readPolicyRevision(),
121
- authorizeCommit: invocation.authorizeCommit,
122
- source: { kind: 'inline-original' }, profile: normalized.profile,
123
- files: normalized.files.map((file) => ({
124
- path: file.path, mimeType: file.mimeType, bytes: Uint8Array.from(file.bytes),
125
- })),
126
- });
127
- if (!outcome.ok) {
128
- const failure = { ...outcome.failure, kind: 'operational' };
129
- Object.defineProperty(failure, 'cause', { value: outcome.failure.cause, enumerable: false });
130
- 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;
131
326
  }
132
- commitState = outcome.commitState;
133
- const receipt = object(outcome.result.receipt);
134
- if (typeof receipt.releaseId !== 'string' || typeof receipt.manifest !== 'string') {
135
- 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 ?? {});
136
341
  }
137
- return { ...outcome, result: { status: 201, body: {
138
- artifactId, releaseId: receipt.releaseId, manifest: receipt.manifest,
139
- digest: parseManifest(receipt.manifest).digest,
140
- ...deps.publicationUrls?.(artifactId, receipt.releaseId),
141
- } } };
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);
142
347
  }
143
348
  if (capability === 'artifacts_get') {
144
349
  const artifactId = normalized.artifactId;
@@ -146,10 +351,36 @@ export function createHttpArtifactApp(deps) {
146
351
  const state = await deps.lifecycle.stateOf(artifactId);
147
352
  const current = await deps.publication.getCurrent(artifactId);
148
353
  return { ok: true, invocationId: invocation.invocationId, commitState: 'not_committed', replayed: false,
149
- result: { status: 200, body: { artifactId, ownerId: acl.ownerId, storageScopeId: acl.storageScopeId,
150
- state, isPublic: acl.isPublic, withdrawn: state === 'withdrawn', currentReleaseId: current?.releaseId,
354
+ result: { status: 200, body: { ...artifactSummary({ artifactId, acl, state, current,
355
+ actorId: invocation.actorId, urls: deps.publicationUrls, approved: deps.isApprovedAccount }),
356
+ ...(acl.metadata !== undefined ? { metadata: acl.metadata } : {}),
151
357
  ...(acl.ownerId === invocation.actorId ? { grants: acl.grants } : {}) } } };
152
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
+ }
153
384
  const visibleArtifacts = [];
154
385
  for (const artifactId of await deps.acls.listArtifactIds()) {
155
386
  const acl = await deps.acls.getArtifactAcl(artifactId);
@@ -157,7 +388,8 @@ export function createHttpArtifactApp(deps) {
157
388
  continue;
158
389
  const state = await deps.lifecycle.stateOf(artifactId);
159
390
  if (deps.lifecycle.canRead({ artifactId, state }, acl, invocation.actorId, 'read', deps.isApprovedAccount)) {
160
- visibleArtifacts.push({ artifactId, ownerId: acl.ownerId, state, isPublic: acl.isPublic });
391
+ visibleArtifacts.push(artifactSummary({ artifactId, acl, state, actorId: invocation.actorId,
392
+ current: await deps.publication.getCurrent(artifactId), urls: deps.publicationUrls, approved: deps.isApprovedAccount }));
161
393
  }
162
394
  }
163
395
  const offset = normalized.offset;
@@ -168,7 +400,7 @@ export function createHttpArtifactApp(deps) {
168
400
  catch (cause) {
169
401
  const failure = classifyArtifactFailure(cause, { invocationId: invocation?.invocationId ?? '',
170
402
  operation, phase, commitState }, (error) => error instanceof ArtifactProfileInputError
171
- ? new ArtifactOperationError(error.category) : deps.mapHostError?.(error));
403
+ ? new ArtifactOperationError(error.category, { publicDetails: error.details }) : deps.mapHostError?.(error));
172
404
  if (cause instanceof CommitQuarantineError) {
173
405
  const migration = { ...failure, kind: 'operational', category: 'provider_unavailable',
174
406
  variant: 'legacy_quarantine', httpStatus: 503, publicCode: 'capability_unavailable', retriable: false };
@@ -178,7 +410,9 @@ export function createHttpArtifactApp(deps) {
178
410
  return { ok: false, failure };
179
411
  }
180
412
  },
181
- 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
+ },
182
416
  };
183
417
  return Object.freeze(app);
184
418
  }
package/dist/index.d.ts CHANGED
@@ -25,3 +25,6 @@ export * from './server/reader-profile.js';
25
25
  export * from './http.js';
26
26
  export * from './server/commit-contract.js';
27
27
  export * from './server/outcome-contract.js';
28
+ export * from './server/source-read.js';
29
+ export * from './server/management.js';
30
+ export * from './adapters/postgres-management.js';
package/dist/index.js CHANGED
@@ -29,3 +29,6 @@ export * from './server/reader-profile.js';
29
29
  export * from './http.js';
30
30
  export * from './server/commit-contract.js';
31
31
  export * from './server/outcome-contract.js';
32
+ export * from './server/source-read.js';
33
+ export * from './server/management.js';
34
+ export * from './adapters/postgres-management.js';
@@ -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.4";
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_get_template" | "artifacts_get_api_guide" | "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";
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.4';
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,12 +25,14 @@ 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 },
32
- gates: { requiresElicitation: false, requiresHumanConfirmation: false, requiresPrincipalGate: false },
35
+ gates: { requiresElicitation: false, requiresHumanConfirmation: spec.name === 'artifacts_delete', requiresPrincipalGate: false },
33
36
  })),
34
37
  authz: { requiredClaims: [], scopes, tenantResolution: 'custom-resolver' },
35
38
  // The existing manifest codec includes audit metadata, including for an empty tool set.
@@ -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':
@@ -64,6 +68,21 @@ export function createArtifactsToolExecutor(deps) {
64
68
  case 'artifacts_get':
65
69
  request = { method: 'GET', path: `/meta/${args.artifactId}`, body: undefined };
66
70
  break;
71
+ case 'artifacts_get_source':
72
+ request = { method: 'POST', path: '/get_source', body: args };
73
+ break;
74
+ case 'artifacts_set_access':
75
+ request = { method: 'POST', path: '/set_access', body: args };
76
+ break;
77
+ case 'artifacts_delete':
78
+ request = { method: 'POST', path: '/delete', body: args };
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;
67
86
  default: throw new ArtifactProfileInputError(true);
68
87
  }
69
88
  return await deps.app.execute({ ...request, actorId: input.invocation.actorId,
@@ -74,7 +93,7 @@ export function createArtifactsToolExecutor(deps) {
74
93
  }
75
94
  }
76
95
  return { execute, async invoke(input) {
77
- return projectArtifactHttpOutcome(await execute(input));
96
+ return projectArtifactHttpOutcome(await execute(input), profile.resultEnvelopeVersion === 2);
78
97
  } };
79
98
  }
80
99
  export function createArtifactsAdapter(options) {
@@ -92,7 +111,7 @@ export function createArtifactsAdapter(options) {
92
111
  let outcome;
93
112
  try {
94
113
  // Selection and closed input checks precede even the host invocation binding.
95
- options.profile.validate(request.capabilityRef, request.input, request.idempotencyKey);
114
+ options.profile.validate(request.capabilityRef, request.input, request.idempotencyKey, { deferPrecondition: true });
96
115
  const invocation = await options.createInvocation(request);
97
116
  if (invocation.actorId !== request.ctx.principal.sub)
98
117
  throw new ArtifactProfileInputError();
@@ -102,7 +121,7 @@ export function createArtifactsAdapter(options) {
102
121
  catch (cause) {
103
122
  outcome = rejected(cause, request.ctx.requestId, request.capabilityRef, options.mapHostError);
104
123
  }
105
- return projectArtifactMcpOutcome(outcome, request.ctx.auditId);
124
+ return projectArtifactMcpOutcome(outcome, request.ctx.auditId, options.profile.resultEnvelopeVersion ?? 1);
106
125
  },
107
126
  readResource: async (request) => ({ ok: false, auditId: request.ctx.auditId, redactionClass: 'low',
108
127
  error: { code: 'not_found', message: 'not_found', retriable: false } }),
@@ -1,7 +1,7 @@
1
1
  import type { JsonValue } from '../contracts.js';
2
2
  import type { EffectiveArtifactCommand } from '../server/command-identity.js';
3
3
  export declare const PROFILE_CONTRACT_VERSION: "artifacts-profile/v1";
4
- export declare const ARTIFACT_CAPABILITY_NAMES: readonly ["artifacts_get_template", "artifacts_get_api_guide", "artifacts_ensure", "artifacts_publish", "artifacts_create_upload", "artifacts_commit_upload", "artifacts_list", "artifacts_get", "artifacts_get_source", "artifacts_fork", "artifacts_set_access", "artifacts_promote", "artifacts_renew", "artifacts_delete", "artifacts_restore"];
4
+ export declare const ARTIFACT_CAPABILITY_NAMES: readonly ["artifacts_get_template", "artifacts_get_api_guide", "artifacts_ensure", "artifacts_publish", "artifacts_create_upload", "artifacts_commit_upload", "artifacts_list", "artifacts_get", "artifacts_get_source", "artifacts_fork", "artifacts_set_access", "artifacts_promote", "artifacts_renew", "artifacts_delete", "artifacts_restore", "artifacts_list_versions", "artifacts_revert"];
5
5
  export type ArtifactCapabilityName = (typeof ARTIFACT_CAPABILITY_NAMES)[number];
6
6
  export type ArtifactSchema = Readonly<Record<string, unknown>>;
7
7
  export interface OperationProfile {
@@ -22,6 +22,12 @@ export interface ArtifactProfileRules {
22
22
  readonly mimeTypes: readonly string[];
23
23
  }
24
24
  export interface ArtifactProviderProfile {
25
+ /** Trusted public contract revision; independent from the host policy revision. */
26
+ readonly profileRevisionId?: string;
27
+ /** Explicit projection switchover; omitted profiles retain the v1 wire shape. */
28
+ readonly resultEnvelopeVersion?: 1 | 2;
29
+ /** Require caller-supplied CAS preconditions on every publication writer. */
30
+ readonly requireExpectedCurrentReleaseId?: boolean;
25
31
  readonly version: typeof PROFILE_CONTRACT_VERSION;
26
32
  readonly selectedCapabilities: readonly string[];
27
33
  readonly operations: readonly OperationProfile[];
@@ -42,7 +48,9 @@ export interface CompiledArtifactsProfile extends EffectiveArtifactProfile {
42
48
  readonly required: true;
43
49
  readonly scope: 'principal';
44
50
  }>>>;
45
- validate(name: string, args: unknown, envelopeKey?: string): Readonly<Record<string, JsonValue>>;
51
+ validate(name: string, args: unknown, envelopeKey?: string, options?: {
52
+ deferPrecondition: true;
53
+ }): Readonly<Record<string, JsonValue>>;
46
54
  assertSelected(name: string): asserts name is ArtifactCapabilityName;
47
55
  capabilityForRoute(method: string, path: string): ArtifactCapabilityName | undefined;
48
56
  }
@@ -53,15 +61,17 @@ export interface CompiledValidators {
53
61
  readonly normalizeCommand: (command: unknown) => EffectiveArtifactCommand;
54
62
  }
55
63
  /** Projection fields are data-only; invocation correlation is added by L6. */
64
+ export declare const PRECONDITION_REQUIRED_MESSAGE = "expectedCurrentReleaseId is required by the active artifact profile. Re-read this artifact with artifacts_get or artifacts_list, then send expectedCurrentReleaseId using the currentReleaseId returned by that read. Send null only if the read confirms that there is no current release. Before editing an existing release, read it with artifacts_get_source and use that releaseId. The server will not infer this value.";
56
65
  export declare class ArtifactProfileInputError extends Error {
66
+ readonly details?: Record<string, unknown> | undefined;
57
67
  readonly kind: 'operational' | 'ordinary';
58
68
  readonly status: number;
59
69
  readonly httpStatus: number;
60
- readonly variant: 'unselected_operation' | 'invalid_request';
70
+ readonly variant: 'unselected_operation' | 'invalid_request' | 'precondition_required';
61
71
  readonly publicCode: string;
62
- readonly category: 'invalid_request' | 'capability_unselected';
72
+ readonly category: 'invalid_request' | 'capability_unselected' | 'precondition_required';
63
73
  readonly retriable = false;
64
74
  readonly phase: 'selection' | 'entry';
65
75
  readonly commitState = "not_committed";
66
- constructor(unselected?: boolean);
76
+ constructor(unselected?: boolean, preconditionRequired?: boolean, details?: Record<string, unknown> | undefined);
67
77
  }