@sentropic/artifacts 0.1.4 → 0.2.0
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/README.md +17 -0
- package/dist/adapters/postgres-commit.d.ts +15 -0
- package/dist/adapters/postgres-commit.js +80 -0
- package/dist/adapters/postgres.d.ts +5 -2
- package/dist/adapters/postgres.js +39 -0
- package/dist/http.d.ts +41 -30
- package/dist/http.js +168 -507
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp-core.d.ts +29 -39
- package/dist/mcp-core.js +83 -244
- package/dist/mcp.js +5 -5
- package/dist/profile/contracts.d.ts +67 -0
- package/dist/profile/contracts.js +31 -0
- package/dist/profile/index.d.ts +6 -0
- package/dist/profile/index.js +117 -0
- package/dist/profile/json.d.ts +10 -0
- package/dist/profile/json.js +76 -0
- package/dist/profile/radar.d.ts +3 -0
- package/dist/profile/radar.js +21 -0
- package/dist/profile/schemas.d.ts +6 -0
- package/dist/profile/schemas.js +76 -0
- package/dist/profile/validation.d.ts +5 -0
- package/dist/profile/validation.js +104 -0
- package/dist/profile/validators.d.ts +3 -0
- package/dist/profile/validators.js +51 -0
- package/dist/server/authorized-replay.d.ts +33 -0
- package/dist/server/authorized-replay.js +31 -0
- package/dist/server/command-identity.d.ts +52 -0
- package/dist/server/command-identity.js +71 -0
- package/dist/server/commit/brands.d.ts +7 -0
- package/dist/server/commit/brands.js +3 -0
- package/dist/server/commit/coordinator.d.ts +2 -0
- package/dist/server/commit/coordinator.js +729 -0
- package/dist/server/commit/types.d.ts +379 -0
- package/dist/server/commit/types.js +94 -0
- package/dist/server/commit-contract.d.ts +4 -0
- package/dist/server/commit-contract.js +2 -0
- package/dist/server/failures.d.ts +84 -0
- package/dist/server/failures.js +133 -0
- package/dist/server/gc.d.ts +42 -1
- package/dist/server/gc.js +168 -5
- package/dist/server/idempotency.d.ts +4 -0
- package/dist/server/idempotency.js +14 -0
- package/dist/server/lifecycle.d.ts +2 -1
- package/dist/server/lifecycle.js +15 -1
- package/dist/server/object-intents.d.ts +14 -1
- package/dist/server/object-intents.js +31 -0
- package/dist/server/outcome-contract.d.ts +3 -0
- package/dist/server/outcome-contract.js +3 -0
- package/dist/server/outcome-projection.d.ts +21 -0
- package/dist/server/outcome-projection.js +29 -0
- package/dist/server/preparation-cleanup.d.ts +31 -0
- package/dist/server/preparation-cleanup.js +106 -0
- package/dist/server/publication.d.ts +76 -0
- package/dist/server/publication.js +270 -31
- package/dist/server/service.d.ts +60 -0
- package/dist/server/service.js +181 -0
- package/dist/testing.d.ts +50 -0
- package/dist/testing.js +86 -0
- package/docs/migration-l6.md +129 -0
- package/examples/radar-commit-setup.ts +299 -0
- package/l0-freeze/command-identity-contract.ts +303 -0
- package/l0-freeze/commit-contract.ts +421 -0
- package/l0-freeze/failure-contract.ts +174 -0
- package/l0-freeze/profile-contract.ts +219 -0
- package/migrations/0003_coordinator.sql +28 -0
- package/package.json +27 -1
- package/src/contracts.ts +125 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { blobDigestOfKey } from './publication.js';
|
|
2
|
+
function preparation(intent) {
|
|
3
|
+
const p = intent.payload;
|
|
4
|
+
if (typeof p.attemptId !== 'string' || typeof p.invocationId !== 'string' ||
|
|
5
|
+
typeof p.preparedAtMs !== 'number' || !Number.isFinite(p.preparedAtMs) ||
|
|
6
|
+
typeof p.cleanupDeadlineMs !== 'number' || !Number.isFinite(p.cleanupDeadlineMs) ||
|
|
7
|
+
p.cleanupDeadlineMs <= p.preparedAtMs || !Array.isArray(p.blobKeys) ||
|
|
8
|
+
!p.blobKeys.every((key) => typeof key === 'string'))
|
|
9
|
+
throw new Error('invalid preparation metadata');
|
|
10
|
+
return { attemptId: p.attemptId, invocationId: p.invocationId,
|
|
11
|
+
preparedAtMs: p.preparedAtMs, cleanupDeadlineMs: p.cleanupDeadlineMs, blobKeys: [...p.blobKeys],
|
|
12
|
+
state: p.cleanupState === 'failed-cleaned' ? 'failed-cleaned' : intent.state === 'failed' ? 'failed' : 'pending-cleanup' };
|
|
13
|
+
}
|
|
14
|
+
async function hasResidualBytes(host, attempt) {
|
|
15
|
+
for (const key of attempt.blobKeys) {
|
|
16
|
+
const digest = blobDigestOfKey(key);
|
|
17
|
+
if (await host.refs.count(digest) === 0 && !await host.refs.isPinned(digest) &&
|
|
18
|
+
(await host.blobs.listKeys(key)).includes(key))
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
/** Historical attempt attribution plus current protection, scoped to one artifact. */
|
|
24
|
+
export async function inspectPreparations(host, artifactId) {
|
|
25
|
+
const intents = await host.intents.listPreparationIntents({ artifactId });
|
|
26
|
+
const attempts = [];
|
|
27
|
+
const invalidIntents = [];
|
|
28
|
+
for (const intent of intents) {
|
|
29
|
+
let attempt;
|
|
30
|
+
try {
|
|
31
|
+
attempt = preparation(intent);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
invalidIntents.push({ intentId: intent.intentId, error: String(error) });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
attempts.push(attempt.state === 'failed-cleaned' && await hasResidualBytes(host, attempt)
|
|
38
|
+
? { ...attempt, state: 'failed' } : attempt);
|
|
39
|
+
}
|
|
40
|
+
const protectedKeys = [], abandonedKeys = [];
|
|
41
|
+
for (const key of [...new Set(attempts.flatMap((attempt) => attempt.blobKeys))].sort()) {
|
|
42
|
+
const digest = blobDigestOfKey(key);
|
|
43
|
+
const protectedBlob = await host.refs.count(digest) > 0 || await host.refs.isPinned(digest);
|
|
44
|
+
(protectedBlob ? protectedKeys : abandonedKeys).push(key);
|
|
45
|
+
}
|
|
46
|
+
return { attempts, protectedKeys, abandonedKeys, invalidIntents };
|
|
47
|
+
}
|
|
48
|
+
/** Run one deterministic, bounded sweep through the supplied clock deadline. */
|
|
49
|
+
export async function scheduleBoundedPreparationCleanup(host, throughMs) {
|
|
50
|
+
const limit = host.cleanupBatchSize ?? 100;
|
|
51
|
+
if (!Number.isFinite(throughMs) || !Number.isSafeInteger(limit) || limit < 1)
|
|
52
|
+
throw new Error('invalid cleanup bound');
|
|
53
|
+
const candidates = await host.intents.listPreparationIntents({ throughMs, limit });
|
|
54
|
+
const failures = [];
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
try {
|
|
57
|
+
let previous;
|
|
58
|
+
try {
|
|
59
|
+
previous = preparation(candidate);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
await host.intents.markFailed(candidate.intentId, `invalid preparation metadata: ${candidate.intentId}`);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
if (previous.state === 'failed-cleaned' && !await hasResidualBytes(host, previous))
|
|
66
|
+
continue;
|
|
67
|
+
const intent = await host.intents.claimPreparationCleanup(candidate.intentId, throughMs);
|
|
68
|
+
if (intent === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
const attempt = preparation(intent);
|
|
71
|
+
if (typeof intent.payload.releaseId !== 'string')
|
|
72
|
+
throw new Error('preparation has no pin owner');
|
|
73
|
+
for (const key of [...new Set(attempt.blobKeys)].sort()) {
|
|
74
|
+
const digest = blobDigestOfKey(key);
|
|
75
|
+
await host.claims.withDigestLock(digest, async (claims) => {
|
|
76
|
+
try {
|
|
77
|
+
await host.refs.unpin(digest, `preparation:${intent.payload.releaseId}`);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!(error instanceof Error) || !/not pinned/.test(error.message))
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
if (await host.refs.count(digest) > 0 || await host.refs.isPinned(digest))
|
|
84
|
+
return;
|
|
85
|
+
// A different purge owns its claim and acknowledges its own external DELETE.
|
|
86
|
+
if (await claims.isClaimed(digest))
|
|
87
|
+
throw new Error('preparation cleanup blocked by deletion claim');
|
|
88
|
+
await claims.claim(digest, `cleanup:${intent.intentId}`);
|
|
89
|
+
try {
|
|
90
|
+
if ((await host.blobs.listKeys(key)).includes(key))
|
|
91
|
+
await host.blobs.deleteBlob(key);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await claims.releaseClaim(digest);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
await host.intents.settlePreparationCleanup(intent.intentId);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
failures.push(new Error(`preparation ${candidate.intentId}: ${String(error)}`, { cause: error }));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (failures.length)
|
|
105
|
+
throw new AggregateError(failures, 'preparation cleanup failed; debt retained for retry');
|
|
106
|
+
}
|
|
@@ -3,6 +3,11 @@ 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';
|
|
8
|
+
import { type RefcountStore } from './gc.js';
|
|
9
|
+
import type { CommandIdentity } from './command-identity.js';
|
|
10
|
+
import type { CommitLockFn, ArtifactCommitCoordinator, FinalizeResult, FinalizeTxClient } from './commit/types.js';
|
|
6
11
|
export { DEFAULT_PUBLICATION_LIMITS, type PublicationLimits } from './publication-limits.js';
|
|
7
12
|
export interface ReleaseRecord {
|
|
8
13
|
releaseId: string;
|
|
@@ -73,3 +78,74 @@ export declare function createPublicationService(deps: {
|
|
|
73
78
|
idempotency?: IdempotencyStore;
|
|
74
79
|
limits?: Partial<PublicationLimits>;
|
|
75
80
|
}): PublicationService;
|
|
81
|
+
/**
|
|
82
|
+
* L4 (immo #861) — guarded publication through the L3 atomic finalization
|
|
83
|
+
* unit (spec r2 A3–A6).
|
|
84
|
+
*
|
|
85
|
+
* Blob bytes are staged and read-back-verified in preparation (no key lock
|
|
86
|
+
* or leased connection spans S3 I/O). The unit then runs caller lifecycle /
|
|
87
|
+
* ACL / pointer locks, provisional release + pointer + refcount writes, the
|
|
88
|
+
* host guard after all waits (lifecycle-active + owner ACL + participant
|
|
89
|
+
* approval), a single success insert with intent completion, and one COMMIT.
|
|
90
|
+
* Denial or conflict rolls everything back: no release, pointer, receipt or
|
|
91
|
+
* (transactional) refcount residue. Same caller key replays the winner.
|
|
92
|
+
*/
|
|
93
|
+
export type PublishDenialReason = 'denied' | 'revoked' | 'invalid_object';
|
|
94
|
+
export interface PublishAuthorizationInput {
|
|
95
|
+
readonly lifecycleState: ArtifactState;
|
|
96
|
+
readonly acl: ArtifactAcl | undefined;
|
|
97
|
+
readonly actorId: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Pure lifecycle/ACL predicate evaluated after the unit waits with fresh
|
|
101
|
+
* reads. Owner-only publication: lifecycle mutations are owner-only across
|
|
102
|
+
* the service, and grant-based publish rights stay a host/L5 decision.
|
|
103
|
+
*/
|
|
104
|
+
export declare function decidePublishAuthorization(input: PublishAuthorizationInput): {
|
|
105
|
+
allowed: true;
|
|
106
|
+
} | {
|
|
107
|
+
allowed: false;
|
|
108
|
+
reason: PublishDenialReason;
|
|
109
|
+
};
|
|
110
|
+
/** Transaction-scoped provisional writers for the finalized unit. */
|
|
111
|
+
export interface PublicationUnitWriter {
|
|
112
|
+
readAuthorization(artifactId: string): Promise<{
|
|
113
|
+
state: ArtifactState;
|
|
114
|
+
acl: ArtifactAcl | undefined;
|
|
115
|
+
}>;
|
|
116
|
+
putSource(artifactId: string, source: SourceRef): Promise<void>;
|
|
117
|
+
putRelease(release: ReleaseRecord): Promise<void>;
|
|
118
|
+
registerBlob(storageScopeId: string, digest: string, size: number): Promise<void>;
|
|
119
|
+
promoteCurrentLatestWins(artifactId: string, releaseId: string): Promise<void>;
|
|
120
|
+
addReleaseRef(digest: string, holder: string): Promise<void>;
|
|
121
|
+
completeIntent(intentId: string): Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
/** PostgreSQL unit writer over the coordinator's leased client. */
|
|
124
|
+
export declare function createPostgresPublicationUnitWriter(client: FinalizeTxClient): PublicationUnitWriter;
|
|
125
|
+
export interface GuardedPublishInput extends PublishInput {
|
|
126
|
+
/** Frozen trusted identity supplied by the host's L1 command normalizer. */
|
|
127
|
+
readonly identity: CommandIdentity;
|
|
128
|
+
readonly actorId: string;
|
|
129
|
+
readonly policyRevision: string;
|
|
130
|
+
readonly callerKey?: string;
|
|
131
|
+
readonly attemptId?: string;
|
|
132
|
+
readonly invocationId?: string;
|
|
133
|
+
}
|
|
134
|
+
export interface GuardedPublicationDeps {
|
|
135
|
+
readonly coordinator: ArtifactCommitCoordinator;
|
|
136
|
+
readonly blobs: PublicationBlobStore;
|
|
137
|
+
readonly intents: IntentStore;
|
|
138
|
+
readonly createUnitWriter: (client: FinalizeTxClient) => PublicationUnitWriter;
|
|
139
|
+
/** Host row locks in PARTICIPANT_LOCK_ORDER classes (PG FOR UPDATE; empty for memory). */
|
|
140
|
+
readonly locks?: Partial<Record<'artifact-lifecycle' | 'acl' | 'current-pointer', CommitLockFn[]>>;
|
|
141
|
+
/** Shared retention store: preparation pins and denial compensation. */
|
|
142
|
+
readonly refs: RefcountStore;
|
|
143
|
+
readonly noteAbandoned?: (attemptId: string) => void | Promise<void>;
|
|
144
|
+
readonly cleanupWindowMs?: number;
|
|
145
|
+
readonly limits?: Partial<PublicationLimits>;
|
|
146
|
+
}
|
|
147
|
+
export interface GuardedPublicationService {
|
|
148
|
+
publishFinalized(input: GuardedPublishInput): Promise<FinalizeResult>;
|
|
149
|
+
}
|
|
150
|
+
/** Create a publication service whose commits finalize through L3 ports. */
|
|
151
|
+
export declare function createGuardedPublicationService(deps: GuardedPublicationDeps): GuardedPublicationService;
|
|
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
|
|
|
15
15
|
import { canonicalManifest, manifestDigest } from '../codecs.js';
|
|
16
16
|
import { parseSourceRef } from './sources.js';
|
|
17
17
|
import { publicationLimits, validatePublicationFiles } from './publication-limits.js';
|
|
18
|
+
import { createPostgresDeletionClaimStore } from './gc.js';
|
|
18
19
|
export { DEFAULT_PUBLICATION_LIMITS } from './publication-limits.js';
|
|
19
20
|
/** Derive the `sha256:<hex>` digest from a digest-addressed blob key. */
|
|
20
21
|
export function blobDigestOfKey(key) {
|
|
@@ -73,30 +74,43 @@ export function createMemoryBlobStore() {
|
|
|
73
74
|
function shaHex(bytes) {
|
|
74
75
|
return createHash('sha256').update(bytes).digest('hex');
|
|
75
76
|
}
|
|
77
|
+
/** Validate-free preparation shared by the split and finalized paths. */
|
|
78
|
+
function preparePublication(input) {
|
|
79
|
+
const entries = input.files.map((file) => {
|
|
80
|
+
const hex = shaHex(file.bytes);
|
|
81
|
+
return {
|
|
82
|
+
entry: { path: file.path, digest: `sha256:${hex}`, size: file.bytes.length, mimeType: file.mimeType },
|
|
83
|
+
key: `artifacts/${input.storageScopeId}/blobs/sha256/${hex}`,
|
|
84
|
+
bytes: file.bytes,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
const body = {
|
|
88
|
+
schemaVersion: 'artifacts/v1',
|
|
89
|
+
artifactId: input.artifactId,
|
|
90
|
+
storageScopeId: input.storageScopeId,
|
|
91
|
+
ownerId: input.ownerId,
|
|
92
|
+
profile: input.profile,
|
|
93
|
+
files: entries.map((e) => e.entry),
|
|
94
|
+
};
|
|
95
|
+
const digest = manifestDigest(body);
|
|
96
|
+
const manifest = canonicalManifest({ ...body, digest });
|
|
97
|
+
return { entries, digest, manifest };
|
|
98
|
+
}
|
|
99
|
+
/** Read-back verification shared by the split and finalized paths. */
|
|
100
|
+
async function verifyStagedBlobs(blobs, entries) {
|
|
101
|
+
for (const { key, bytes } of entries) {
|
|
102
|
+
const stored = await blobs.getBlob(key);
|
|
103
|
+
if (stored === undefined || shaHex(stored) !== shaHex(bytes)) {
|
|
104
|
+
throw new Error(`publication digest mismatch for ${key}: read-back failed`);
|
|
105
|
+
}
|
|
106
|
+
if (stored.length !== bytes.length) {
|
|
107
|
+
throw new Error(`publication size mismatch for ${key}: read-back failed`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
76
111
|
/** Create a publication service over injected stores (no driver imports). */
|
|
77
112
|
export function createPublicationService(deps) {
|
|
78
113
|
const limits = publicationLimits(deps.limits);
|
|
79
|
-
function prepare(input) {
|
|
80
|
-
const entries = input.files.map((file) => {
|
|
81
|
-
const hex = shaHex(file.bytes);
|
|
82
|
-
return {
|
|
83
|
-
entry: { path: file.path, digest: `sha256:${hex}`, size: file.bytes.length, mimeType: file.mimeType },
|
|
84
|
-
key: `artifacts/${input.storageScopeId}/blobs/sha256/${hex}`,
|
|
85
|
-
bytes: file.bytes,
|
|
86
|
-
};
|
|
87
|
-
});
|
|
88
|
-
const body = {
|
|
89
|
-
schemaVersion: 'artifacts/v1',
|
|
90
|
-
artifactId: input.artifactId,
|
|
91
|
-
storageScopeId: input.storageScopeId,
|
|
92
|
-
ownerId: input.ownerId,
|
|
93
|
-
profile: input.profile,
|
|
94
|
-
files: entries.map((e) => e.entry),
|
|
95
|
-
};
|
|
96
|
-
const digest = manifestDigest(body);
|
|
97
|
-
const manifest = canonicalManifest({ ...body, digest });
|
|
98
|
-
return { entries, digest, manifest };
|
|
99
|
-
}
|
|
100
114
|
async function commitOnce(input, prepared, sources) {
|
|
101
115
|
const releaseId = crypto.randomUUID();
|
|
102
116
|
const { entries, digest, manifest } = prepared;
|
|
@@ -109,15 +123,7 @@ export function createPublicationService(deps) {
|
|
|
109
123
|
for (const { key, bytes } of entries) {
|
|
110
124
|
await deps.blobs.putBlob(key, bytes);
|
|
111
125
|
}
|
|
112
|
-
|
|
113
|
-
const stored = await deps.blobs.getBlob(key);
|
|
114
|
-
if (stored === undefined || shaHex(stored) !== shaHex(bytes)) {
|
|
115
|
-
throw new Error(`publication digest mismatch for ${key}: read-back failed`);
|
|
116
|
-
}
|
|
117
|
-
if (stored.length !== bytes.length) {
|
|
118
|
-
throw new Error(`publication size mismatch for ${key}: read-back failed`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
126
|
+
await verifyStagedBlobs(deps.blobs, entries);
|
|
121
127
|
await deps.state.putRelease({
|
|
122
128
|
releaseId,
|
|
123
129
|
artifactId: input.artifactId,
|
|
@@ -145,7 +151,7 @@ export function createPublicationService(deps) {
|
|
|
145
151
|
if (source !== undefined && sources === undefined)
|
|
146
152
|
throw new Error('invalid publication: missing source store');
|
|
147
153
|
input = { ...input, source, files: input.files.map((file) => ({ ...file, bytes: new Uint8Array(file.bytes) })) };
|
|
148
|
-
const prepared =
|
|
154
|
+
const prepared = preparePublication(input);
|
|
149
155
|
const requestHash = createHash('sha256').update(JSON.stringify([prepared.digest, source ?? null])).digest('hex');
|
|
150
156
|
if (input.idempotencyKey !== undefined && deps.idempotency !== undefined) {
|
|
151
157
|
const { result } = await deps.idempotency.execute(JSON.stringify(['publish', input.ownerId, input.idempotencyKey]), requestHash, () => commitOnce(input, prepared, sources));
|
|
@@ -170,3 +176,236 @@ export function createPublicationService(deps) {
|
|
|
170
176
|
},
|
|
171
177
|
};
|
|
172
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Pure lifecycle/ACL predicate evaluated after the unit waits with fresh
|
|
181
|
+
* reads. Owner-only publication: lifecycle mutations are owner-only across
|
|
182
|
+
* the service, and grant-based publish rights stay a host/L5 decision.
|
|
183
|
+
*/
|
|
184
|
+
export function decidePublishAuthorization(input) {
|
|
185
|
+
if (input.lifecycleState === 'withdrawn')
|
|
186
|
+
return { allowed: false, reason: 'revoked' };
|
|
187
|
+
if (input.lifecycleState === 'deleted' || input.lifecycleState === 'purged') {
|
|
188
|
+
return { allowed: false, reason: 'invalid_object' };
|
|
189
|
+
}
|
|
190
|
+
if (input.lifecycleState !== 'active')
|
|
191
|
+
return { allowed: false, reason: 'revoked' };
|
|
192
|
+
if (input.acl === undefined || input.acl.ownerId !== input.actorId) {
|
|
193
|
+
return { allowed: false, reason: 'denied' };
|
|
194
|
+
}
|
|
195
|
+
return { allowed: true };
|
|
196
|
+
}
|
|
197
|
+
/** PostgreSQL unit writer over the coordinator's leased client. */
|
|
198
|
+
export function createPostgresPublicationUnitWriter(client) {
|
|
199
|
+
return {
|
|
200
|
+
async readAuthorization(artifactId) {
|
|
201
|
+
const { rows } = await client.query('SELECT lifecycle_state, owner_id FROM artifacts.artifacts WHERE id = $1', [artifactId]);
|
|
202
|
+
const row = rows[0];
|
|
203
|
+
return { state: row?.lifecycle_state ?? 'purged',
|
|
204
|
+
acl: row === undefined ? undefined : { ownerId: row.owner_id, grants: [], isPublic: false,
|
|
205
|
+
withdrawn: row.lifecycle_state === 'withdrawn' } };
|
|
206
|
+
},
|
|
207
|
+
async putSource(artifactId, source) {
|
|
208
|
+
await client.query(`INSERT INTO artifacts.artifact_sources (artifact_id, kind, commit_tx, steps)
|
|
209
|
+
VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (artifact_id) DO UPDATE
|
|
210
|
+
SET kind = EXCLUDED.kind, commit_tx = EXCLUDED.commit_tx, steps = EXCLUDED.steps`, [artifactId, source.kind, source.kind === 'base-commit' ? source.commit : null,
|
|
211
|
+
source.kind === 'recipe' ? JSON.stringify(source.steps) : null]);
|
|
212
|
+
},
|
|
213
|
+
async putRelease(release) {
|
|
214
|
+
await client.query(`INSERT INTO artifacts.releases (id, artifact_id, manifest_digest, manifest, state, created_at)
|
|
215
|
+
VALUES ($1, $2, $3, $4, 'ready', $5)`, [release.releaseId, release.artifactId, release.manifestDigest, release.manifest, release.createdAt]);
|
|
216
|
+
},
|
|
217
|
+
async registerBlob(storageScopeId, digest, size) {
|
|
218
|
+
await client.query(`INSERT INTO artifacts.blobs (storage_scope_id, digest, size)
|
|
219
|
+
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [storageScopeId, digest, size]);
|
|
220
|
+
},
|
|
221
|
+
async promoteCurrentLatestWins(artifactId, releaseId) {
|
|
222
|
+
// CR-3 latest-wins: last committer wins the pointer, no loser conflict.
|
|
223
|
+
await client.query(`INSERT INTO artifacts.artifact_current (artifact_id, release_id)
|
|
224
|
+
VALUES ($1, $2) ON CONFLICT (artifact_id) DO UPDATE SET release_id = EXCLUDED.release_id`, [artifactId, releaseId]);
|
|
225
|
+
},
|
|
226
|
+
async addReleaseRef(digest, holder) {
|
|
227
|
+
await createPostgresDeletionClaimStore(client).withDigestLock(digest, async (claims) => {
|
|
228
|
+
if (await claims.isClaimed(digest))
|
|
229
|
+
throw new Error(`blob scheduled for deletion: ${digest}`);
|
|
230
|
+
await client.query(`INSERT INTO artifacts.blob_ref_holders (digest, holder)
|
|
231
|
+
VALUES ($1, $2) ON CONFLICT DO NOTHING`, [digest, holder]);
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
async completeIntent(intentId) {
|
|
235
|
+
const { rowCount } = await client.query(`UPDATE artifacts.object_intents SET state = 'done', updated_at = now() WHERE id = $1
|
|
236
|
+
AND state = 'pending' AND (NOT (payload ? 'cleanupDeadlineMs')
|
|
237
|
+
OR (payload->>'cleanupDeadlineMs')::numeric > EXTRACT(EPOCH FROM clock_timestamp()) * 1000)`, [intentId]);
|
|
238
|
+
if (rowCount === 0)
|
|
239
|
+
throw new Error('preparation expired or abandoned; explicit retry required');
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const DEFAULT_CLEANUP_WINDOW_MS = 15 * 60 * 1000;
|
|
244
|
+
/** Create a publication service whose commits finalize through L3 ports. */
|
|
245
|
+
export function createGuardedPublicationService(deps) {
|
|
246
|
+
const limits = publicationLimits(deps.limits);
|
|
247
|
+
const cleanupWindowMs = deps.cleanupWindowMs ?? DEFAULT_CLEANUP_WINDOW_MS;
|
|
248
|
+
if (!Number.isSafeInteger(cleanupWindowMs) || cleanupWindowMs < 1)
|
|
249
|
+
throw new Error('invalid preparation lifetime');
|
|
250
|
+
const guard = (client) => async (context, transaction) => {
|
|
251
|
+
const { state, acl } = await deps.createUnitWriter(client()).readAuthorization(context.artifactId);
|
|
252
|
+
const verdict = decidePublishAuthorization({ lifecycleState: state, acl, actorId: context.actorId });
|
|
253
|
+
if (!verdict.allowed) {
|
|
254
|
+
return {
|
|
255
|
+
allowed: false,
|
|
256
|
+
reason: verdict.reason,
|
|
257
|
+
publicCode: 'access_denied',
|
|
258
|
+
status: 403,
|
|
259
|
+
retriable: false,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const approval = await transaction.participant.checkApproval({
|
|
263
|
+
actorId: context.actorId,
|
|
264
|
+
operation: context.operation,
|
|
265
|
+
});
|
|
266
|
+
if (!approval.approved) {
|
|
267
|
+
return {
|
|
268
|
+
allowed: false,
|
|
269
|
+
reason: approval.reason ?? 'denied',
|
|
270
|
+
publicCode: 'access_denied',
|
|
271
|
+
status: 403,
|
|
272
|
+
retriable: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const revision = await transaction.participant.readRevision();
|
|
276
|
+
if (revision !== context.policyRevision) {
|
|
277
|
+
return {
|
|
278
|
+
allowed: false,
|
|
279
|
+
reason: 'revoked',
|
|
280
|
+
publicCode: 'access_denied',
|
|
281
|
+
status: 403,
|
|
282
|
+
retriable: false,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return { allowed: true, fence: transaction.participant.mintFence(revision, context.invocationId) };
|
|
286
|
+
};
|
|
287
|
+
return {
|
|
288
|
+
async publishFinalized(input) {
|
|
289
|
+
validatePublicationFiles(input.files, limits);
|
|
290
|
+
const prepared = preparePublication(input);
|
|
291
|
+
const { internalKey, commandHash, codecVersion } = input.identity;
|
|
292
|
+
const callerKey = input.callerKey ?? internalKey;
|
|
293
|
+
const releaseId = crypto.randomUUID();
|
|
294
|
+
const invocationId = input.invocationId ?? crypto.randomUUID();
|
|
295
|
+
const attemptId = input.attemptId ?? releaseId;
|
|
296
|
+
const nowMs = Date.now();
|
|
297
|
+
const intent = await deps.intents.record('commit-release', {
|
|
298
|
+
artifactId: input.artifactId,
|
|
299
|
+
releaseId,
|
|
300
|
+
manifestDigest: prepared.digest,
|
|
301
|
+
blobKeys: prepared.entries.map((e) => e.key),
|
|
302
|
+
attemptId,
|
|
303
|
+
invocationId,
|
|
304
|
+
operation: 'publish',
|
|
305
|
+
preparedAtMs: nowMs,
|
|
306
|
+
cleanupDeadlineMs: nowMs + cleanupWindowMs,
|
|
307
|
+
});
|
|
308
|
+
const pinned = [];
|
|
309
|
+
const pinReason = `preparation:${releaseId}`;
|
|
310
|
+
let preservePins = false;
|
|
311
|
+
try {
|
|
312
|
+
for (const digest of [...new Set(prepared.entries.map((entry) => entry.entry.digest))].sort()) {
|
|
313
|
+
await deps.refs.pin(digest, pinReason);
|
|
314
|
+
pinned.push(digest);
|
|
315
|
+
}
|
|
316
|
+
await deps.coordinator.testObserver?.arrive('before_blob_put', invocationId);
|
|
317
|
+
for (const { key, bytes } of prepared.entries) {
|
|
318
|
+
if ((await deps.intents.get(intent.intentId))?.state !== 'pending') {
|
|
319
|
+
throw new Error('preparation abandoned before blob PUT');
|
|
320
|
+
}
|
|
321
|
+
await deps.blobs.putBlob(key, bytes);
|
|
322
|
+
}
|
|
323
|
+
await deps.coordinator.testObserver?.arrive('after_blob_put_before_verify', invocationId);
|
|
324
|
+
await deps.coordinator.testObserver?.arrive('before_blob_readback', invocationId);
|
|
325
|
+
await verifyStagedBlobs(deps.blobs, prepared.entries);
|
|
326
|
+
const addedRefs = [];
|
|
327
|
+
const source = input.source === undefined ? undefined : parseSourceRef(input.source);
|
|
328
|
+
let finalizationClient;
|
|
329
|
+
const request = {
|
|
330
|
+
scopeId: input.storageScopeId,
|
|
331
|
+
internalKey,
|
|
332
|
+
commandHash,
|
|
333
|
+
codecVersion,
|
|
334
|
+
invocationId,
|
|
335
|
+
actorId: input.actorId,
|
|
336
|
+
operation: 'publish',
|
|
337
|
+
artifactId: input.artifactId,
|
|
338
|
+
bindingName: null,
|
|
339
|
+
policyRevision: input.policyRevision,
|
|
340
|
+
quotaBytes: prepared.entries.reduce((total, entry) => total + entry.bytes.byteLength, 0),
|
|
341
|
+
locks: { ...deps.locks, 'artifact-lifecycle': [
|
|
342
|
+
async (client) => { finalizationClient = client; },
|
|
343
|
+
...(deps.locks?.['artifact-lifecycle'] ?? []),
|
|
344
|
+
] },
|
|
345
|
+
provisional: async (client) => {
|
|
346
|
+
const unit = deps.createUnitWriter(client);
|
|
347
|
+
await unit.putRelease({
|
|
348
|
+
releaseId,
|
|
349
|
+
artifactId: input.artifactId,
|
|
350
|
+
manifestDigest: prepared.digest,
|
|
351
|
+
manifest: prepared.manifest,
|
|
352
|
+
state: 'ready',
|
|
353
|
+
createdAt: new Date(nowMs).toISOString(),
|
|
354
|
+
});
|
|
355
|
+
for (const staged of prepared.entries) {
|
|
356
|
+
await unit.registerBlob(input.storageScopeId, staged.entry.digest, staged.entry.size);
|
|
357
|
+
await unit.addReleaseRef(staged.entry.digest, releaseId);
|
|
358
|
+
addedRefs.push(staged.entry.digest);
|
|
359
|
+
}
|
|
360
|
+
await unit.promoteCurrentLatestWins(input.artifactId, releaseId);
|
|
361
|
+
if (source !== undefined)
|
|
362
|
+
await unit.putSource(input.artifactId, source);
|
|
363
|
+
},
|
|
364
|
+
guard: guard(() => {
|
|
365
|
+
if (finalizationClient === undefined)
|
|
366
|
+
throw new Error('publication guard needs the finalization client');
|
|
367
|
+
return finalizationClient;
|
|
368
|
+
}),
|
|
369
|
+
finishIntents: async (client) => {
|
|
370
|
+
await deps.createUnitWriter(client).completeIntent(intent.intentId);
|
|
371
|
+
},
|
|
372
|
+
noteAbandonedAttempt: deps.noteAbandoned === undefined
|
|
373
|
+
? undefined
|
|
374
|
+
: async (attemptId) => {
|
|
375
|
+
await deps.noteAbandoned?.(attemptId);
|
|
376
|
+
},
|
|
377
|
+
attemptId,
|
|
378
|
+
receiptData: { releaseId, manifest: prepared.manifest },
|
|
379
|
+
};
|
|
380
|
+
preservePins = true;
|
|
381
|
+
const outcome = await deps.coordinator.finalizePrepared(request);
|
|
382
|
+
preservePins = !outcome.ok && outcome.failure.commitState === 'unknown';
|
|
383
|
+
if (!outcome.ok && outcome.failure.commitState === 'not_committed') {
|
|
384
|
+
// Best-effort compensation for ref stores outside the unit transaction.
|
|
385
|
+
for (const digest of addedRefs) {
|
|
386
|
+
try {
|
|
387
|
+
await deps.refs.releaseRef(digest, releaseId);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
/* already rolled back or never added */
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return outcome;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
if (!preservePins) {
|
|
398
|
+
for (const digest of pinned) {
|
|
399
|
+
try {
|
|
400
|
+
await deps.refs.unpin(digest, pinReason);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
if (!(error instanceof Error) || !/not pinned/.test(error.message))
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
package/dist/server/service.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { ServerPorts } from './ports.js';
|
|
2
|
+
import { type CompiledArtifactsProfile } from '../profile/index.js';
|
|
3
|
+
import { type ArtifactKeyDerivationBinding } from './command-identity.js';
|
|
4
|
+
import type { ArtifactInvocationContext, ArtifactCommitCoordinator, FinalizeTxClient, InternalIdempotencyKey } from './commit/types.js';
|
|
5
|
+
import type { ArtifactOutcome } from './failures.js';
|
|
2
6
|
export interface EnsurePrivateViewInput {
|
|
3
7
|
actorUserId: string;
|
|
4
8
|
ownerId: string;
|
|
@@ -20,3 +24,59 @@ export interface ArtifactService {
|
|
|
20
24
|
}
|
|
21
25
|
/** Create the artifact service over explicit injected ports (no defaults). */
|
|
22
26
|
export declare function createArtifactService(ports: ServerPorts): ArtifactService;
|
|
27
|
+
/**
|
|
28
|
+
* L5 (immo #861) — coordinator-bound ensure (spec r2 §§3.1 A1-A8, 3.2).
|
|
29
|
+
*
|
|
30
|
+
* The legacy split finalizer above stays intact for the L6-owned HTTP and
|
|
31
|
+
* MCP consumers; L6 removes it during transport wiring. This composition is
|
|
32
|
+
* the only guarded ensure path: closed profile validation, server-derived
|
|
33
|
+
* command identity (never a caller hash or scope), request-bound guard
|
|
34
|
+
* invoked after all waits inside one atomic unit, typed outcomes shared
|
|
35
|
+
* with the failure projection. Unknown legacy codecs propagate as
|
|
36
|
+
* quarantine for the L6 migration projection.
|
|
37
|
+
*/
|
|
38
|
+
export interface GuardedEnsureArgs {
|
|
39
|
+
readonly bindingName: string;
|
|
40
|
+
readonly parameters?: Record<string, string | number | boolean | null>;
|
|
41
|
+
readonly idempotencyKey: string;
|
|
42
|
+
readonly envelopeKey?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface GuardedEnsureResult {
|
|
45
|
+
readonly artifactId: string;
|
|
46
|
+
readonly created: boolean;
|
|
47
|
+
readonly replayed: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** Binding identity resolved inside the finalization unit. */
|
|
50
|
+
export interface EnsureBindingInput {
|
|
51
|
+
readonly ownerId: string;
|
|
52
|
+
readonly storageScopeId: string;
|
|
53
|
+
readonly bindingName: string;
|
|
54
|
+
readonly attemptId: string;
|
|
55
|
+
}
|
|
56
|
+
export interface EnsureBindingReceipt {
|
|
57
|
+
readonly artifactId: string;
|
|
58
|
+
readonly created: boolean;
|
|
59
|
+
}
|
|
60
|
+
/** Host-owned ensure effects inside the coordinator transaction. */
|
|
61
|
+
export interface GuardedEnsureHost {
|
|
62
|
+
lockBinding(client: FinalizeTxClient, binding: EnsureBindingInput): Promise<void>;
|
|
63
|
+
resolveBinding(client: FinalizeTxClient, binding: EnsureBindingInput): Promise<EnsureBindingReceipt>;
|
|
64
|
+
abandonAttempt(attemptId: string): Promise<void>;
|
|
65
|
+
readPolicyRevision(): Promise<string>;
|
|
66
|
+
}
|
|
67
|
+
export interface GuardedEnsureDeps {
|
|
68
|
+
readonly profile: CompiledArtifactsProfile;
|
|
69
|
+
readonly coordinator: ArtifactCommitCoordinator;
|
|
70
|
+
readonly derivation: ArtifactKeyDerivationBinding;
|
|
71
|
+
readonly host: GuardedEnsureHost;
|
|
72
|
+
}
|
|
73
|
+
export interface GuardedReplayArgs {
|
|
74
|
+
readonly internalKey: InternalIdempotencyKey;
|
|
75
|
+
readonly commandHash: string;
|
|
76
|
+
readonly binding?: EnsureBindingInput;
|
|
77
|
+
}
|
|
78
|
+
export interface GuardedEnsureService {
|
|
79
|
+
ensure(invocation: ArtifactInvocationContext, args: GuardedEnsureArgs): Promise<ArtifactOutcome<GuardedEnsureResult>>;
|
|
80
|
+
replay(invocation: ArtifactInvocationContext, args: GuardedReplayArgs): Promise<ArtifactOutcome<GuardedEnsureResult>>;
|
|
81
|
+
}
|
|
82
|
+
export declare function createGuardedEnsureService(deps: GuardedEnsureDeps): GuardedEnsureService;
|