@lmzhen/dsh-evolution-activity 0.17.0 → 0.18.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/lib/index.js CHANGED
@@ -70,17 +70,36 @@ function parseActivityContent(raw) {
70
70
  }
71
71
  }
72
72
  /**
73
- * H-06: the read barrier over the sidecar, with the corruption verdict.
73
+ * One parse, one verdict — every reader-facing answer above is derived from this one call.
74
+ * @param raw - the sidecar bytes, or null when the file is absent.
75
+ * @returns the verdict.
76
+ */
77
+ function activityVerdict(raw) {
78
+ if (raw === null) return "absent";
79
+ try {
80
+ const parsed = JSON.parse(raw);
81
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.items)) return "unreadable";
82
+ const version = parsed.version;
83
+ return version !== void 0 && version !== 2 ? "foreign" : "readable";
84
+ } catch {
85
+ return "unreadable";
86
+ }
87
+ }
88
+ /**
89
+ * H-06: the read barrier over the sidecar, with the two verdicts it must not conflate.
74
90
  *
75
91
  * @param root - the evolution state root (the sidecar lives under it).
76
92
  * @param io - the IO provider to read through.
77
- * @returns the parsed records and whether the bytes were readable.
93
+ * @returns the parsed records, plus whether they are MISSING (`corrupt`) and whether a newer
94
+ * writer wrote the envelope (`foreignVersion`).
78
95
  */
79
96
  async function loadActivityState(root, io) {
80
97
  const raw = await io.readText(activityFile(root));
98
+ const verdict = activityVerdict(raw);
81
99
  return {
82
100
  records: parseActivityContent(raw),
83
- corrupt: isCorruptActivity(raw)
101
+ corrupt: verdict === "unreadable",
102
+ foreignVersion: verdict === "foreign"
84
103
  };
85
104
  }
86
105
  /**
@@ -96,35 +115,23 @@ async function loadActivityState(root, io) {
96
115
  async function loadActivity(root, io) {
97
116
  return (await loadActivityState(root, io)).records;
98
117
  }
99
- /** True when bytes exist but are not a readable activity envelope: unparsable
100
- * JSON, or a missing `items` array (a scalar/array/other-shaped file). A missing
101
- * file (null) is NOT corruption — it is a first write. */
102
118
  /**
103
- * v43 audit (H-3 / J-6 / FLOW6-6): an UNSUPPORTED version is corrupt for this
104
- * writer. The read side has always ignored `version` and taken `items` as-is,
105
- * so a future-version sidecar used to fold fine and then be rewritten as
106
- * `version: ACTIVITY_FILE_VERSION` on the next append — a silent downgrade that
107
- * destroyed whatever the newer format carried. This guard is the write side's
108
- * half of the pair: unknown version ⇒ the original bytes are quarantined (the
109
- * existing `.corrupt` path) before a current-version file replaces them, which
110
- * is the posture `evolution-events` already takes for the same shape.
111
- * Residual, closed by FLOW6-6: `parseActivityContent` still answers `[]` for
112
- * such a file, but {@link loadActivityState} now carries the verdict beside the
113
- * records, so a read-only consumer can distinguish "newer format" from "no
114
- * history" instead of presenting a partial view as the recorded truth.
119
+ * The WRITE side's quarantine predicate: should these bytes be preserved under `.corrupt` before a
120
+ * current-version file replaces them?
121
+ *
122
+ * v43 audit (H-3 / J-6 / FLOW6-6): yes for an envelope declaring an UNSUPPORTED version. The read
123
+ * side has always ignored `version` and taken `items` as-is, so a future-version sidecar used to
124
+ * fold fine and then be rewritten as `version: ACTIVITY_FILE_VERSION` on the next append — a silent
125
+ * downgrade that destroyed whatever the newer format carried. This is the `foreign` verdict from
126
+ * {@link activityVerdict}, which is why this predicate is NOT the read side's `corrupt`: a foreign
127
+ * envelope's records ARE read (only the bytes are worth preserving), while `corrupt` means there
128
+ * were no readable records in the first place.
115
129
  * @internal Exported for this package's own tests (siblings `parseActivityContent`
116
130
  * and `serializeActivity` are exported for the same reason).
117
131
  */
118
132
  function isCorruptActivity(raw) {
119
- if (raw === null) return false;
120
- try {
121
- const parsed = JSON.parse(raw);
122
- if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.items)) return true;
123
- const version = parsed.version;
124
- return version !== void 0 && version !== 2;
125
- } catch {
126
- return true;
127
- }
133
+ const verdict = activityVerdict(raw);
134
+ return verdict === "unreadable" || verdict === "foreign";
128
135
  }
129
136
  const name = "evolution-activity";
130
137
  const Config = z.object({ maxItems: z.number().min(1).default(200) });
@@ -57,25 +57,39 @@ export declare function applyActivityEvent(items: EvolutionActivityRecord[], eve
57
57
  export declare function serializeActivity(items: EvolutionActivityRecord[]): string;
58
58
  export declare function parseActivityContent(raw: string | null): EvolutionActivityRecord[];
59
59
  /**
60
- * FLOW6-6 (v43, the read side of H-3): the sidecar's records PLUS whether the
61
- * bytes could be read as THIS format. `parseActivityContent` answers `[]` for
62
- * a future-version or unparsable file, so a read-only consumer could not tell
63
- * "newer format" from "no history" and presented a partial/empty view as the
64
- * recorded truth. The write side already quarantines those bytes
65
- * ({@link isCorruptActivity}); this is the channel the readers were missing.
60
+ * FLOW6-6 (v43, the read side of H-3) + T4-08/A50: the sidecar's records PLUS two independent
61
+ * verdicts, because two different things were being folded into one word.
62
+ *
63
+ * `parseActivityContent` answers `[]` for UNPARSABLE bytes or a file with no `items` array — and it
64
+ * reads the items AS-IS when the envelope declares a version this build does not know (the historical
65
+ * behaviour, pinned by activity-version-gate.spec.ts). So "the records are missing" and "a newer
66
+ * writer wrote this" are different facts, and each field names exactly one: `corrupt` is the first,
67
+ * `foreignVersion` the second. (The write side still quarantines the bytes in BOTH cases —
68
+ * {@link isCorruptActivity} — because only a byte-level copy survives the next append.) Collapsing
69
+ * the two made replay backfill its leaderboard from those records AND declare the view "NOT the
70
+ * recorded history".
66
71
  */
67
72
  export interface ActivityLoad {
68
73
  records: EvolutionActivityRecord[];
69
- /** Bytes exist but are not a readable current-version envelope. A MISSING
70
- * file is not corruption — it is a first write. */
74
+ /** Bytes exist but are not a readable CURRENT-version envelope at all:
75
+ * unparsable JSON, or no `items` array. A MISSING file is not corruption —
76
+ * it is a first write, and `records` is empty in both cases. */
71
77
  corrupt: boolean;
78
+ /** T4-08/A50: the envelope declared a `version` this reader does not know,
79
+ * AND its `items` were still read (the historical behaviour, pinned by
80
+ * activity-version-gate.spec.ts). This is NOT corruption: the records ARE the
81
+ * recorded history, just written by a newer writer whose unknown fields this
82
+ * build ignores. A consumer that qualifies such a read as "not the recorded
83
+ * history" contradicts the leaderboard it just backfilled from it. */
84
+ foreignVersion: boolean;
72
85
  }
73
86
  /**
74
- * H-06: the read barrier over the sidecar, with the corruption verdict.
87
+ * H-06: the read barrier over the sidecar, with the two verdicts it must not conflate.
75
88
  *
76
89
  * @param root - the evolution state root (the sidecar lives under it).
77
90
  * @param io - the IO provider to read through.
78
- * @returns the parsed records and whether the bytes were readable.
91
+ * @returns the parsed records, plus whether they are MISSING (`corrupt`) and whether a newer
92
+ * writer wrote the envelope (`foreignVersion`).
79
93
  */
80
94
  export declare function loadActivityState(root: string, io: EvolutionIoLike): Promise<ActivityLoad>;
81
95
  /**
@@ -89,22 +103,17 @@ export declare function loadActivityState(root: string, io: EvolutionIoLike): Pr
89
103
  * sidecar as "no history" use {@link loadActivityState}.
90
104
  */
91
105
  export declare function loadActivity(root: string, io: EvolutionIoLike): Promise<EvolutionActivityRecord[]>;
92
- /** True when bytes exist but are not a readable activity envelope: unparsable
93
- * JSON, or a missing `items` array (a scalar/array/other-shaped file). A missing
94
- * file (null) is NOT corruption — it is a first write. */
95
106
  /**
96
- * v43 audit (H-3 / J-6 / FLOW6-6): an UNSUPPORTED version is corrupt for this
97
- * writer. The read side has always ignored `version` and taken `items` as-is,
98
- * so a future-version sidecar used to fold fine and then be rewritten as
99
- * `version: ACTIVITY_FILE_VERSION` on the next append — a silent downgrade that
100
- * destroyed whatever the newer format carried. This guard is the write side's
101
- * half of the pair: unknown version ⇒ the original bytes are quarantined (the
102
- * existing `.corrupt` path) before a current-version file replaces them, which
103
- * is the posture `evolution-events` already takes for the same shape.
104
- * Residual, closed by FLOW6-6: `parseActivityContent` still answers `[]` for
105
- * such a file, but {@link loadActivityState} now carries the verdict beside the
106
- * records, so a read-only consumer can distinguish "newer format" from "no
107
- * history" instead of presenting a partial view as the recorded truth.
107
+ * The WRITE side's quarantine predicate: should these bytes be preserved under `.corrupt` before a
108
+ * current-version file replaces them?
109
+ *
110
+ * v43 audit (H-3 / J-6 / FLOW6-6): yes for an envelope declaring an UNSUPPORTED version. The read
111
+ * side has always ignored `version` and taken `items` as-is, so a future-version sidecar used to
112
+ * fold fine and then be rewritten as `version: ACTIVITY_FILE_VERSION` on the next append — a silent
113
+ * downgrade that destroyed whatever the newer format carried. This is the `foreign` verdict from
114
+ * {@link activityVerdict}, which is why this predicate is NOT the read side's `corrupt`: a foreign
115
+ * envelope's records ARE read (only the bytes are worth preserving), while `corrupt` means there
116
+ * were no readable records in the first place.
108
117
  * @internal Exported for this package's own tests (siblings `parseActivityContent`
109
118
  * and `serializeActivity` are exported for the same reason).
110
119
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-activity",
3
3
  "description": "Durable activity store for self-evolution plan outcomes (community build)",
4
- "version": "0.17.0",
4
+ "version": "0.18.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -26,16 +26,16 @@
26
26
  ],
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@deepseek-ai/schemastery": "^3.18.1",
30
- "@lmzhen/dsh-evolution-core": "^0.17.0"
29
+ "@deepseek-ai/schemastery": "^3.18.4",
30
+ "@lmzhen/dsh-evolution-core": "^0.18.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@deepseek-ai/cordis": "^4.0.1"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@deepseek-ai/cordis": "^4.0.1",
37
- "@lmzhen/dsh-evolution-core": "^0.17.0",
38
- "@lmzhen/dsh-evolution-io": "^0.17.0",
39
- "@lmzhen/dsh-evolution-io-node": "^0.17.0"
37
+ "@lmzhen/dsh-evolution-core": "^0.18.0",
38
+ "@lmzhen/dsh-evolution-io": "^0.18.0",
39
+ "@lmzhen/dsh-evolution-io-node": "^0.18.0"
40
40
  }
41
41
  }