@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.
- package/CHANGELOG.md +34 -0
- package/README.md +51 -3
- package/dist/adapters/postgres-management.d.ts +4 -0
- package/dist/adapters/postgres-management.js +36 -0
- package/dist/adapters/postgres.js +30 -8
- package/dist/http.d.ts +11 -1
- package/dist/http.js +272 -38
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp-core.d.ts +3 -3
- package/dist/mcp-core.js +29 -10
- package/dist/profile/contracts.d.ts +15 -5
- package/dist/profile/contracts.js +9 -6
- package/dist/profile/index.js +39 -4
- package/dist/profile/radar.js +4 -1
- package/dist/profile/schemas.js +41 -8
- package/dist/profile/validation.d.ts +3 -1
- package/dist/profile/validation.js +36 -13
- package/dist/server/access.d.ts +14 -0
- package/dist/server/command-identity.d.ts +6 -3
- package/dist/server/command-identity.js +8 -8
- package/dist/server/commit/coordinator.js +13 -10
- package/dist/server/commit/types.d.ts +4 -0
- package/dist/server/edits.d.ts +17 -0
- package/dist/server/edits.js +43 -0
- package/dist/server/failures.d.ts +34 -2
- package/dist/server/failures.js +17 -3
- package/dist/server/lifecycle.d.ts +4 -0
- package/dist/server/lifecycle.js +15 -0
- package/dist/server/management.d.ts +46 -0
- package/dist/server/management.js +86 -0
- package/dist/server/outcome-projection.d.ts +2 -2
- package/dist/server/outcome-projection.js +25 -7
- package/dist/server/publication.d.ts +22 -11
- package/dist/server/publication.js +56 -7
- package/dist/server/result-schema.d.ts +14 -0
- package/dist/server/result-schema.js +35 -0
- package/dist/server/service.d.ts +2 -0
- package/dist/server/service.js +2 -0
- package/dist/server/source-read.d.ts +38 -0
- package/dist/server/source-read.js +68 -0
- package/dist/server/summary.d.ts +40 -0
- package/dist/server/summary.js +18 -0
- package/dist/server/templates.js +24 -3
- package/dist/server/versions.d.ts +29 -0
- package/dist/server/versions.js +39 -0
- package/docs/migration-l6.md +8 -1
- package/migrations/0004_terminal_controls.sql +47 -0
- package/migrations/0005_metadata.sql +14 -0
- 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. */
|
package/dist/server/failures.js
CHANGED
|
@@ -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':
|
|
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:
|
|
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;
|
package/dist/server/lifecycle.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) => {
|
|
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
|
|
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
|
+
};
|