wowbagger 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
@@ -263,6 +263,10 @@ It returns `result.read_back` with exactly `ledger_namespace`, `item_id`,
263
263
  reservation. It is the recovery operation after a lost claim response: the
264
264
  caller reads the tuple before retrying an acquire, renew, or release.
265
265
 
266
+ `work-claim.read`, capability discovery, and `ledger-publication.read` are
267
+ observational. They MUST NOT take the namespace write lock, advance the durable
268
+ clock floor, reconcile publications, or change coordinator files.
269
+
266
270
  ### Acquire and takeover
267
271
 
268
272
  `work-claim.acquire` accepts exactly:
@@ -467,10 +471,11 @@ journal; that log is a derived audit artifact, not authority.
467
471
 
468
472
  A clean verification returns exit 0 and `state: "committed"`. Findings named
469
473
  `pending-intent-resolved` are clean recovery. Any
470
- `publication-outcome-unknown`, `revision-regression`, or
471
- `stale-write-detected` finding returns exit 6 and `state: "unknown"`. A caller
472
- MUST stop publication work and inspect those findings. Repeating verification
473
- MUST NOT duplicate a publication finalization.
474
+ `legacy-mutation-outcome-unknown`, `publication-outcome-unknown`,
475
+ `revision-regression`, or `stale-write-detected` finding returns exit 6 and
476
+ `state: "unknown"`. A caller MUST stop publication work and inspect those
477
+ findings. Repeating verification MUST NOT duplicate a publication
478
+ finalization.
474
479
 
475
480
  The top-level `state: "committed"` describes durable reconciliation state, not
476
481
  Git finalization of every successful publication. A caller MUST gate Git
@@ -498,6 +503,18 @@ create MUST reject any item identity whose tuple has claim history with exit 4
498
503
  high-water mark. Both checks persist authoritative decision time before their
499
504
  response.
500
505
 
506
+ Before a merge-coordinated backend permits a legacy transition or patch to
507
+ write bytes, it MUST fsync a `legacy-mutation-intent` with the expected and
508
+ candidate revisions. Under the same namespace lock, it MUST reserve journal
509
+ capacity for that intent, one recovery clock entry, and one terminal entry. A
510
+ committed write appends `legacy-mutation`; an unchanged write appends
511
+ `legacy-mutation-abort`. Reconciliation resolves a pending intent to the
512
+ committed terminal when the candidate revision is present, or to the abort
513
+ terminal when the expected revision remains. Any third revision produces
514
+ `legacy-mutation-outcome-unknown`. The latest committed terminal is the
515
+ authorized expected revision. A later unrecorded revision remains a stale
516
+ write.
517
+
501
518
  An implementation may instead route a legacy write through `publish-claimed`,
502
519
  but it cannot silently omit a fence. Administrative repair, bulk import,
503
520
  plugins, direct database writes, and filesystem writers count as alternate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wowbagger",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Plain-Markdown, Git-native work ledger for coordinating agents — validate, ready-select, and mutate a task ledger from the CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "src",
37
37
  "skills",
38
38
  "adapters",
39
+ "docs/adapter-contract.md",
39
40
  "docs/mutation-contract.md",
40
41
  "docs/work-claim-contract.md",
41
42
  "scripts/migrate-schema-2.js",
@@ -45,6 +46,7 @@
45
46
  ],
46
47
  "scripts": {
47
48
  "check": "npm test && git diff --check && git diff --cached --check",
49
+ "report": "node bin/wowbagger.js report --ledger ledger --json",
48
50
  "test": "node --test test/*.test.js",
49
51
  "prepublishOnly": "node bin/wowbagger.js validate --ledger ledger --json >/dev/null && node --check bin/wowbagger.js && node scripts/verify-release-tag.js"
50
52
  },
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: wowbagger
3
- description: Use when coordinating work through a wowbagger ledger — reading the ready queue, inspecting or filing an item, transitioning one through its lifecycle, taking a work claim, or publishing claimed work. Triggers on "ready queue", "what should I work on", "file a ledger item", "close this item", "claim this work", "publish claimed work", or any mention of a wowbagger ledger. Not for general backlog talk where no wowbagger ledger exists.
3
+ description: Use when coordinating work through a wowbagger ledger — reading the ready queue, inspecting or filing an item, generating an HTML ledger report, transitioning one through its lifecycle, taking a work claim, or publishing claimed work. Triggers on "ready queue", "ledger report", "backlog report", "what should I work on", "file a ledger item", "close this item", "claim this work", "publish claimed work", or any mention of a wowbagger ledger. Not for general backlog talk where no wowbagger ledger exists.
4
4
  ---
5
5
 
6
6
  # Wowbagger
@@ -15,23 +15,29 @@ This skill does **not** bundle the wowbagger core. It drives an installed one,
15
15
  so a version mismatch is detectable rather than silent.
16
16
 
17
17
  ```sh
18
+ wowbagger --version
18
19
  wowbagger capabilities --json
19
20
  ```
20
21
 
21
- Read the top-level `contract_version` from this core response. **This skill
22
- requires core `contract_version: 2`.**
22
+ Read the plain distribution version from the first command and the top-level
23
+ `contract_version` from the second. **This skill requires distribution version
24
+ `0.1.0-alpha.4` and core `contract_version: 2`.**
23
25
 
24
26
  - Command not found → the core is not installed. Tell the user, point them at
25
27
  <https://github.com/lstutzman/wowbagger>, and stop. Do not fall back to
26
28
  editing ledger files by hand — hand-edits bypass validation and atomic
27
29
  publication, which is the whole point of the tool.
30
+ - If the distribution version is missing or is different, stop and report the
31
+ installed and required versions. An older core can share contract version 2
32
+ while still lacking behavior this skill requires. Do not guess from the
33
+ contract version alone.
28
34
  - `contract_version` is anything other than `2` → stop and say so plainly. A
29
35
  core reporting `1` predates schema version 2, where `depends_on` records
30
36
  declared prerequisites rather than only live blockers; an older or newer core
31
37
  may have changed the request or response shape. Do not guess.
32
38
 
33
- Run this once per session before the first ledger command, not before every
34
- command.
39
+ Run both commands once per session before the first ledger command, not before
40
+ every command.
35
41
 
36
42
  ## Reading
37
43
 
@@ -55,6 +61,32 @@ wowbagger capabilities --json
55
61
  revision is what `transition` compares against, so inspect immediately before
56
62
  transitioning.
57
63
 
64
+ ## Generating an HTML report
65
+
66
+ The report is read-only derived output. Its configuration is
67
+ `<ledger>/.wowbagger/report.json`.
68
+
69
+ ```sh
70
+ wowbagger report --ledger <dir> --as-of <YYYY-MM-DD> --json
71
+ ```
72
+
73
+ Use today's date for `--as-of`. Use `--out <file>` only when the caller needs to
74
+ override the configured output. The resolved output must stay outside the
75
+ ledger. Do not use an npm script as a machine protocol; invoke `wowbagger`
76
+ directly so standard output contains exactly one compact JSON object.
77
+
78
+ On success, require exit `0`, `ok: true`, `command: "report"`,
79
+ `contract_version: 2`, `result.report_version: 1`, and the requested
80
+ `result.as_of`. Read the generated file from the absolute `result.output`.
81
+ On failure, require `ok: false` and inspect `error.code`; do not treat an
82
+ existing output as fresh because failed publication preserves the prior report.
83
+
84
+ The generated HTML does not authorize transitions, claims, or parallel work.
85
+ It has no live ledger revision. Its readiness state is the canonical projection
86
+ at generation time, but the file is only a static view. Area-diverse batches are
87
+ scheduling hints. Use `inspect` plus `transition` for lifecycle changes and the
88
+ claim commands below for coordination.
89
+
58
90
  ## Writing
59
91
 
60
92
  Every write is an explicit, reviewable Git change. Show the user the command
@@ -26,6 +26,18 @@ const STANDARD_LIMITS = Object.freeze({
26
26
  });
27
27
  const PROBE_LIVE_CORE = Symbol('probe-live-core');
28
28
 
29
+ function invalidDescribeResponse(details) {
30
+ return {
31
+ ok: false,
32
+ bootstrap_wire_version: 1,
33
+ error: {
34
+ code: 'invalid-describe-request',
35
+ message: 'The adapter describe request is invalid.',
36
+ details,
37
+ },
38
+ };
39
+ }
40
+
29
41
  export function standardDynamicResult(manifest, coreProbe) {
30
42
  return {
31
43
  ok: true,
@@ -296,17 +308,19 @@ export async function runAdapterEntrypoint({
296
308
  details: incoming.detail,
297
309
  },
298
310
  }
299
- : { ok: false, error: { code: incoming.error_code } };
311
+ : invalidDescribeResponse(incoming.detail);
300
312
  await writeBootstrapResponse(process.stdout, response);
301
313
  return;
302
314
  }
303
315
 
304
316
  if (operation === 'describe') {
305
317
  const described = describeAdapter(incoming.request, manifest, dynamic);
306
- await writeBootstrapResponse(
307
- process.stdout,
308
- described.ok ? described.result : { ok: false, error: { code: described.error_code } },
309
- );
318
+ const response = described.ok
319
+ ? described.result
320
+ : described.error_code === 'invalid-describe-request'
321
+ ? invalidDescribeResponse({ member: described.detail })
322
+ : { ok: false, error: { code: described.error_code } };
323
+ await writeBootstrapResponse(process.stdout, response);
310
324
  return;
311
325
  }
312
326
 
@@ -1,5 +1,11 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
 
2
- import { claimJournalPath, replayClaimJournal } from './claim-journal.js';
3
+ import {
4
+ appendClaimEntry,
5
+ assertClaimJournalCapacity,
6
+ claimJournalPath,
7
+ replayClaimJournal,
8
+ } from './claim-journal.js';
3
9
  import { resolveWorkClaimCapability } from './claim-capabilities.js';
4
10
  import { readBack } from './claim-operations.js';
5
11
  import { reconcileClaimJournal } from './claim-publication.js';
@@ -7,13 +13,19 @@ import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './cl
7
13
  import { readNamespace } from './namespace.js';
8
14
 
9
15
  export async function withLegacyMutationFence(ledgerDirectory, itemId, command, write) {
10
- const gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory);
16
+ let gitCommonDir;
17
+ try {
18
+ gitCommonDir = await resolveVerifiedGitCommonDir(ledgerDirectory, { failClosed: true });
19
+ } catch {
20
+ return claimStoreUnavailable(command, 'git-verification-failed');
21
+ }
11
22
  const namespace = gitCommonDir ? await readNamespace(ledgerDirectory) : null;
12
23
  const capability = resolveWorkClaimCapability({ gitCommonDir, namespace });
13
24
  if (!capability.claim_protected_publication) return write();
14
25
 
15
26
  const storePath = claimStorePath(gitCommonDir, namespace);
16
27
  const journalPath = claimJournalPath(gitCommonDir, namespace);
28
+ let intent = null;
17
29
  try {
18
30
  return await withClaimLock(storePath, async () => {
19
31
  const replayed = await replayClaimJournal(journalPath, namespace);
@@ -35,13 +47,107 @@ export async function withLegacyMutationFence(ledgerDirectory, itemId, command,
35
47
  const mustRefuse = command === 'create-v1'
36
48
  ? record.last_epoch !== '0'
37
49
  : record.active !== null && observedAt < record.active.expires_at;
38
- if (!mustRefuse) return write();
39
- return legacyRefusal(command, namespace, itemId, observedAt, record);
50
+ if (mustRefuse) return legacyRefusal(command, namespace, itemId, observedAt, record);
51
+
52
+ const authorize = async (expectedRevision, candidateRevision) => {
53
+ const attemptId = randomUUID();
54
+ const intentEntry = {
55
+ type: 'legacy-mutation-intent',
56
+ attempt_id: attemptId,
57
+ ledger_namespace: namespace,
58
+ item_id: itemId,
59
+ command,
60
+ expected_revision: expectedRevision,
61
+ candidate_revision: candidateRevision,
62
+ observed_at: observedAt,
63
+ };
64
+ const terminalEntry = {
65
+ type: 'legacy-mutation',
66
+ attempt_id: attemptId,
67
+ ledger_namespace: namespace,
68
+ item_id: itemId,
69
+ command,
70
+ committed_revision: candidateRevision,
71
+ observed_at: observedAt,
72
+ };
73
+ const abortEntry = {
74
+ type: 'legacy-mutation-abort',
75
+ attempt_id: attemptId,
76
+ ledger_namespace: namespace,
77
+ item_id: itemId,
78
+ observed_revision: expectedRevision,
79
+ observed_at: observedAt,
80
+ };
81
+ const resolutionEntry = Buffer.byteLength(JSON.stringify(terminalEntry))
82
+ >= Buffer.byteLength(JSON.stringify(abortEntry))
83
+ ? terminalEntry
84
+ : abortEntry;
85
+ await assertClaimJournalCapacity(journalPath, [
86
+ intentEntry,
87
+ { type: 'clock', now: observedAt, floor: observedAt },
88
+ resolutionEntry,
89
+ ]);
90
+ intent = await appendClaimEntry(journalPath, intentEntry);
91
+ };
92
+ let outcome;
93
+ try {
94
+ outcome = await write(authorize);
95
+ } catch (error) {
96
+ if (intent) {
97
+ return claimStoreUnavailable(command, 'legacy-mutation-outcome-unknown', {
98
+ attempt_id: intent.attempt_id,
99
+ candidate_revision: intent.candidate_revision,
100
+ }, 'unknown');
101
+ }
102
+ throw error;
103
+ }
104
+ if (!intent) return outcome;
105
+ if (outcome?.state === 'committed') {
106
+ const committedRevision = outcome.ok === true
107
+ ? outcome.item?.revision
108
+ : outcome.error?.details?.revision;
109
+ if (committedRevision !== intent.candidate_revision) {
110
+ return claimStoreUnavailable(command, 'legacy-mutation-outcome-unknown', {
111
+ attempt_id: intent.attempt_id,
112
+ candidate_revision: intent.candidate_revision,
113
+ }, 'unknown');
114
+ }
115
+ try {
116
+ await appendClaimEntry(journalPath, {
117
+ type: 'legacy-mutation',
118
+ attempt_id: intent.attempt_id,
119
+ ledger_namespace: namespace,
120
+ item_id: itemId,
121
+ command,
122
+ committed_revision: committedRevision,
123
+ observed_at: observedAt,
124
+ });
125
+ } catch {
126
+ return claimStoreUnavailable(command, 'legacy-mutation-record-failed', {
127
+ attempt_id: intent.attempt_id,
128
+ committed_revision: committedRevision,
129
+ }, 'unknown');
130
+ }
131
+ } else if (outcome?.state === 'unchanged') {
132
+ try {
133
+ await appendClaimEntry(journalPath, {
134
+ type: 'legacy-mutation-abort',
135
+ attempt_id: intent.attempt_id,
136
+ ledger_namespace: namespace,
137
+ item_id: itemId,
138
+ observed_revision: intent.expected_revision,
139
+ observed_at: observedAt,
140
+ });
141
+ } catch {
142
+ return claimStoreUnavailable(command, 'legacy-mutation-record-failed');
143
+ }
144
+ }
145
+ return outcome;
40
146
  });
41
147
  } catch (error) {
42
148
  return claimStoreUnavailable(command, error?.code === 'CLAIM_LOCK_HELD'
43
149
  ? 'claim-store-locked'
44
- : 'claim-store-unreadable');
150
+ : 'claim-store-unreadable', {}, intent ? 'unknown' : 'unchanged');
45
151
  }
46
152
  }
47
153
 
@@ -66,7 +172,7 @@ function legacyRefusal(command, namespace, itemId, observedAt, record) {
66
172
  };
67
173
  }
68
174
 
69
- function claimStoreUnavailable(command, reason, details = {}) {
175
+ function claimStoreUnavailable(command, reason, details = {}, state = 'unchanged') {
70
176
  return {
71
177
  exit: 6,
72
178
  stdout: {
@@ -74,7 +180,7 @@ function claimStoreUnavailable(command, reason, details = {}) {
74
180
  namespace: 'ledger-mutation',
75
181
  command,
76
182
  contract_version: 1,
77
- state: 'unchanged',
183
+ state,
78
184
  error: {
79
185
  code: 'claim-store-unavailable',
80
186
  message: 'The durable claim store is unavailable.',
@@ -3,7 +3,7 @@ import { mkdir, open, readFile, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { validateClaimRequest } from './claim-request.js';
5
5
 
6
- import { advanceClockFloor, claimAcquire, claimRead, claimRelease, claimRenew } from './claim-operations.js';
6
+ import { advanceClockFloor, claimAcquire, claimRelease, claimRenew } from './claim-operations.js';
7
7
  import { emptyClaimState } from './claim-store.js';
8
8
 
9
9
  const MAX_JOURNAL_ENTRIES = 65536;
@@ -12,6 +12,9 @@ const MAX_RECONCILE_LOG_BYTES = MAX_JOURNAL_BYTES + 1024;
12
12
  const JOURNAL_ENTRY_TYPES = new Set([
13
13
  'claim',
14
14
  'clock',
15
+ 'legacy-mutation',
16
+ 'legacy-mutation-abort',
17
+ 'legacy-mutation-intent',
15
18
  'publish-final',
16
19
  'publish-finalization',
17
20
  'publish-intent',
@@ -43,10 +46,23 @@ export async function appendClaimEntry(journalPath, entry) {
43
46
  return persisted;
44
47
  }
45
48
 
49
+ export async function assertClaimJournalCapacity(journalPath, plannedEntries) {
50
+ const entries = await readJournalEntries(journalPath);
51
+ if (entries.length + plannedEntries.length > MAX_JOURNAL_ENTRIES) {
52
+ throw journalCapacityExceeded();
53
+ }
54
+ let plannedBytes = await fileSize(journalPath);
55
+ for (const [index, entry] of plannedEntries.entries()) {
56
+ const persisted = { seq: entries.length + index + 1, ...entry };
57
+ plannedBytes += Buffer.byteLength(`${JSON.stringify(persisted)}\n`);
58
+ }
59
+ if (plannedBytes > MAX_JOURNAL_BYTES) throw journalCapacityExceeded();
60
+ }
61
+
46
62
  export async function replayClaimJournal(journalPath, namespace) {
47
63
  const entries = await readJournalEntries(journalPath, namespace);
48
64
  let state = emptyClaimState(namespace);
49
- const operations = { acquire: claimAcquire, read: claimRead, release: claimRelease, renew: claimRenew };
65
+ const operations = { acquire: claimAcquire, release: claimRelease, renew: claimRenew };
50
66
  for (const entry of entries) {
51
67
  if (entry.type === 'clock') {
52
68
  advanceClockFloor(state, entry.floor);
@@ -177,6 +193,33 @@ function validJournalEntry(entry, namespace) {
177
193
  && validateClaimRequest(entry.command, entry.request).length === 0
178
194
  && (namespace === null || entry.request.ledger_namespace === namespace);
179
195
  }
196
+ if (entry.type === 'legacy-mutation-intent') {
197
+ return typeof entry.attempt_id === 'string'
198
+ && typeof entry.ledger_namespace === 'string'
199
+ && (namespace === null || entry.ledger_namespace === namespace)
200
+ && typeof entry.item_id === 'string'
201
+ && ['patch-v1', 'transition-v1'].includes(entry.command)
202
+ && typeof entry.expected_revision === 'string'
203
+ && typeof entry.candidate_revision === 'string'
204
+ && typeof entry.observed_at === 'string';
205
+ }
206
+ if (entry.type === 'legacy-mutation') {
207
+ return (!Object.hasOwn(entry, 'attempt_id') || typeof entry.attempt_id === 'string')
208
+ && typeof entry.ledger_namespace === 'string'
209
+ && (namespace === null || entry.ledger_namespace === namespace)
210
+ && typeof entry.item_id === 'string'
211
+ && ['patch-v1', 'transition-v1'].includes(entry.command)
212
+ && typeof entry.committed_revision === 'string'
213
+ && typeof entry.observed_at === 'string';
214
+ }
215
+ if (entry.type === 'legacy-mutation-abort') {
216
+ return typeof entry.attempt_id === 'string'
217
+ && typeof entry.ledger_namespace === 'string'
218
+ && (namespace === null || entry.ledger_namespace === namespace)
219
+ && typeof entry.item_id === 'string'
220
+ && typeof entry.observed_revision === 'string'
221
+ && typeof entry.observed_at === 'string';
222
+ }
180
223
  if (entry.type === 'publish-intent') {
181
224
  return typeof entry.operation_id === 'string'
182
225
  && typeof entry.operation_digest === 'string'
@@ -24,17 +24,28 @@ const EPOCH = /^(?:[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-
24
24
  const MAX_CANDIDATE_BYTES = 8388608;
25
25
  const MAX_CANDIDATE_BASE64_CHARS = Math.ceil(MAX_CANDIDATE_BYTES / 3) * 4;
26
26
 
27
+ const PUBLICATION_MEMBERS = [
28
+ 'candidate_sha256',
29
+ 'candidate_source_base64',
30
+ 'claim_fence',
31
+ 'expected_revision',
32
+ 'item_id',
33
+ 'ledger_namespace',
34
+ 'operation_id',
35
+ ];
36
+
27
37
  export function validatePublicationRequest(request) {
28
- const required = [
29
- 'candidate_sha256',
30
- 'candidate_source_base64',
31
- 'claim_fence',
32
- 'expected_revision',
33
- 'item_id',
34
- 'ledger_namespace',
35
- 'operation_id',
36
- ];
37
- if (!isExactObject(request, required)
38
+ if (isExactObject(request, ['operation_id'])
39
+ && OPERATION_ID.test(request.operation_id)) {
40
+ return publicationError(
41
+ request,
42
+ 'invalid-request',
43
+ 'The publish-claimed retry must include its complete request.',
44
+ {},
45
+ 2,
46
+ );
47
+ }
48
+ if (!isExactObject(request, PUBLICATION_MEMBERS)
38
49
  || !OPERATION_ID.test(request.operation_id)
39
50
  || !NAMESPACE_ID.test(request.ledger_namespace)
40
51
  || !ITEM_ID.test(request.item_id)
@@ -89,43 +100,40 @@ export function validatePublicationReadRequest(request) {
89
100
  }
90
101
 
91
102
  export async function readPublicationOutcome({ gitCommonDir, namespace, request }) {
92
- const storePath = claimStorePath(gitCommonDir, namespace);
93
103
  const journalPath = claimJournalPath(gitCommonDir, namespace);
94
104
  try {
95
- return await withClaimLock(storePath, async () => {
96
- const replayed = await replayClaimJournal(journalPath, namespace);
97
- const outcome = replayed.entries.find((entry) => (
98
- entry.type === 'publish-final'
99
- && entry.operation_id === request.operation_id
100
- && entry.item_id === request.item_id
101
- ));
102
- if (!outcome) {
103
- return publicationReadError(request, 'operation-not-found',
104
- 'The publication operation outcome was not found.', {
105
- operation_id: request.operation_id,
106
- ledger_namespace: request.ledger_namespace,
107
- item_id: request.item_id,
108
- });
109
- }
110
- return {
111
- exit: 0,
112
- stdout: {
113
- ok: true,
114
- namespace: 'ledger-publication',
115
- command: 'read',
116
- contract_version: 1,
117
- state: 'committed',
105
+ const replayed = await replayClaimJournal(journalPath, namespace);
106
+ const outcome = replayed.entries.find((entry) => (
107
+ entry.type === 'publish-final'
108
+ && entry.operation_id === request.operation_id
109
+ && entry.item_id === request.item_id
110
+ ));
111
+ if (!outcome) {
112
+ return publicationReadError(request, 'operation-not-found',
113
+ 'The publication operation outcome was not found.', {
118
114
  operation_id: request.operation_id,
119
- result: {
120
- operation_id: request.operation_id,
121
- ledger_namespace: request.ledger_namespace,
122
- item_id: request.item_id,
123
- operation_digest: outcome.operation_digest,
124
- outcome: structuredClone(outcome.outcome.stdout),
125
- },
115
+ ledger_namespace: request.ledger_namespace,
116
+ item_id: request.item_id,
117
+ });
118
+ }
119
+ return {
120
+ exit: 0,
121
+ stdout: {
122
+ ok: true,
123
+ namespace: 'ledger-publication',
124
+ command: 'read',
125
+ contract_version: 1,
126
+ state: 'committed',
127
+ operation_id: request.operation_id,
128
+ result: {
129
+ operation_id: request.operation_id,
130
+ ledger_namespace: request.ledger_namespace,
131
+ item_id: request.item_id,
132
+ operation_digest: outcome.operation_digest,
133
+ outcome: structuredClone(outcome.outcome),
126
134
  },
127
- };
128
- });
135
+ },
136
+ };
129
137
  } catch (error) {
130
138
  return publicationReadError(request, 'claim-store-unavailable',
131
139
  'The durable claim store is unavailable.', {
@@ -282,6 +290,49 @@ export async function reconcileClaimJournal({
282
290
  error.code = 'CLOCK_FLOOR_PERSISTENCE_FAILED';
283
291
  throw error;
284
292
  }
293
+ const legacyMutationTerminals = new Set(entries
294
+ .filter((entry) => (
295
+ ['legacy-mutation', 'legacy-mutation-abort'].includes(entry.type)
296
+ && typeof entry.attempt_id === 'string'
297
+ ))
298
+ .map((entry) => entry.attempt_id));
299
+ const pendingLegacyMutations = entries.filter((entry) => (
300
+ entry.type === 'legacy-mutation-intent'
301
+ && !legacyMutationTerminals.has(entry.attempt_id)
302
+ ));
303
+ for (const intent of pendingLegacyMutations) {
304
+ const item = items.get(intent.item_id);
305
+ const actualRevision = item ? revisionFor(item.bytes) : null;
306
+ if (actualRevision === intent.candidate_revision) {
307
+ entries.push(await appendClaimEntry(journalPath, {
308
+ type: 'legacy-mutation',
309
+ attempt_id: intent.attempt_id,
310
+ ledger_namespace: namespace,
311
+ item_id: intent.item_id,
312
+ command: intent.command,
313
+ committed_revision: actualRevision,
314
+ observed_at: observedAt,
315
+ }));
316
+ } else if (actualRevision === intent.expected_revision) {
317
+ entries.push(await appendClaimEntry(journalPath, {
318
+ type: 'legacy-mutation-abort',
319
+ attempt_id: intent.attempt_id,
320
+ ledger_namespace: namespace,
321
+ item_id: intent.item_id,
322
+ observed_revision: actualRevision,
323
+ observed_at: observedAt,
324
+ }));
325
+ } else {
326
+ findings.push({
327
+ code: 'legacy-mutation-outcome-unknown',
328
+ item_id: intent.item_id,
329
+ attempt_id: intent.attempt_id,
330
+ actual_revision: actualRevision,
331
+ expected_revision: intent.expected_revision,
332
+ candidate_revision: intent.candidate_revision,
333
+ });
334
+ }
335
+ }
285
336
 
286
337
  for (const intent of pending) {
287
338
  const item = items.get(intent.item_id);
@@ -394,29 +445,40 @@ export async function reconcileClaimJournal({
394
445
  }
395
446
  }
396
447
 
397
- const finalizedItems = new Set(entries
398
- .filter((entry) => entry.type === 'publish-finalization')
448
+ const coordinatedItems = new Set(entries
449
+ .filter((entry) => (
450
+ entry.type === 'publish-finalization'
451
+ || entry.type === 'legacy-mutation'
452
+ ))
399
453
  .map((entry) => entry.item_id));
400
- for (const itemId of finalizedItems) {
401
- const published = entries.filter((entry) => (
402
- entry.type === 'publish-final'
454
+ for (const itemId of coordinatedItems) {
455
+ const authorized = entries.filter((entry) => (
456
+ (entry.type === 'publish-final'
403
457
  && entry.item_id === itemId
404
- && entry.outcome?.stdout?.state === 'committed'
458
+ && entry.outcome?.stdout?.state === 'committed')
459
+ || (entry.type === 'legacy-mutation' && entry.item_id === itemId)
405
460
  ));
406
- const finalizations = entries.filter((entry) => (
407
- entry.type === 'publish-finalization' && entry.item_id === itemId
408
- ));
409
- const expectedRevision = published.at(-1)?.outcome.stdout.result.committed_revision;
410
- const finalizedRevision = finalizations.at(-1)?.committed_revision;
411
- if (!expectedRevision || !finalizedRevision) continue;
461
+ const latestAuthorized = authorized.at(-1);
462
+ const expectedRevision = latestAuthorized?.type === 'legacy-mutation'
463
+ ? latestAuthorized.committed_revision
464
+ : latestAuthorized?.outcome.stdout.result.committed_revision;
465
+ const authorizedRevisions = new Set(authorized.map((entry) => (
466
+ entry.type === 'legacy-mutation'
467
+ ? entry.committed_revision
468
+ : entry.outcome.stdout.result.committed_revision
469
+ )));
470
+ for (const intent of entries) {
471
+ if (intent.type === 'legacy-mutation-intent' && intent.item_id === itemId) {
472
+ authorizedRevisions.add(intent.expected_revision);
473
+ }
474
+ }
475
+ if (!expectedRevision) continue;
412
476
  const item = items.get(itemId);
413
477
  const actualRevision = item ? revisionFor(item.bytes) : null;
414
478
  const headItem = headItems.get(itemId);
415
479
  const headRevision = headItem ? revisionFor(headItem.bytes) : null;
416
480
  const workingTreeChanged = actualRevision !== expectedRevision;
417
- const gitHeadChanged = gitHead !== null
418
- && headRevision !== expectedRevision
419
- && headRevision !== finalizedRevision;
481
+ const gitHeadChanged = gitHead !== null && !authorizedRevisions.has(headRevision);
420
482
  if (!workingTreeChanged && !gitHeadChanged) continue;
421
483
  const record = replayed.state.claims.find((entry) => entry.item_id === itemId);
422
484
  findings.push({
@@ -438,7 +500,7 @@ export async function reconcileClaimJournal({
438
500
 
439
501
 
440
502
  for (const record of replayed.state.claims) {
441
- if (finalizedItems.has(record.item_id)) continue;
503
+ if (coordinatedItems.has(record.item_id)) continue;
442
504
  if (record.active === null || observedAt >= record.active.expires_at) continue;
443
505
  const committed = entries.filter((entry) => (
444
506
  entry.type === 'publish-final'