@saasontools/strauss-kb 0.1.3 → 0.1.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.
package/README.md CHANGED
@@ -151,7 +151,31 @@ before publishing, which narrows the lost-update window rather than closing it.
151
151
  [ARCHITECTURE.md](./ARCHITECTURE.md) says why a lock was rejected.
152
152
 
153
153
  `supersede` writes both directions, so a backlink cannot drift in normal use and
154
- `validate` drops to catching hand-edits.
154
+ `validate` drops to catching hand-edits. A `write` (or `write-decision`) that
155
+ carries `supersedes` does the same: the new record publishes first, then each
156
+ prior record it names is marked superseded in turn — a crash between the two
157
+ leaves an old record with no backlink, which `validate` already reports as
158
+ "is not marked superseded", never a silent drift. A `supersedes` id naming a
159
+ record that does not exist yet is legal and does not fail the write; `validate`
160
+ is what reports a target that never resolves. A `supersedes` id naming the
161
+ record's own concept id is a no-op rather than an error, duplicate ids mark
162
+ once, and the array is capped at 32 entries.
163
+
164
+ A concurrent writer marking the same target races the compare-and-swap check;
165
+ that's retried a few times before giving up, and giving up is reported the
166
+ same way as a target that doesn't exist yet — left out of `supersededIds` for
167
+ `validate` to catch, not thrown, since the calling record is already
168
+ published by that point. If two different records both name the same target
169
+ in `supersedes`, the target's backlink points at whichever wrote last;
170
+ `validate` doesn't see this as a problem because the target genuinely is
171
+ superseded, but `kb_query`/`kb_load`'s adjudication surfaces the resulting
172
+ fork as a warning at read time.
173
+
174
+ `kb_write` and `kb_write_decision` return
175
+ `{ conceptId, action: "created" | "superseded-prior", supersededIds }` —
176
+ `supersededIds` is only the ids actually marked, not every id the input named.
177
+ A 409 from a concept-id collision carries `action: "refused"` in its `details`,
178
+ alongside the `conceptId`.
155
179
 
156
180
  Records are never deleted. Superseding keeps the earlier reasoning inspectable,
157
181
  which is what a later `trace` reads.
@@ -180,7 +180,7 @@ var composeInputSchema = z2.object({
180
180
  /** Concept ids this record relates to; rendered as body links. */
181
181
  relatedConceptIds: z2.array(kbConceptIdSchema).optional(),
182
182
  /** Concept ids this record replaces. The store settles the backlinks. */
183
- supersedes: z2.array(kbConceptIdSchema).optional(),
183
+ supersedes: z2.array(kbConceptIdSchema).max(32).optional(),
184
184
  materiality: z2.enum(KB_MATERIALITIES).optional(),
185
185
  confidence: z2.enum(KB_CONFIDENCES).optional(),
186
186
  owner: z2.string().min(1).optional()
@@ -1561,7 +1561,11 @@ var writeCommand = define({
1561
1561
  composeRecord(type, input, actor, now()),
1562
1562
  actor
1563
1563
  );
1564
- return { conceptId: record.conceptId };
1564
+ return {
1565
+ conceptId: record.conceptId,
1566
+ action: record.action,
1567
+ supersededIds: record.supersededIds
1568
+ };
1565
1569
  }
1566
1570
  });
1567
1571
 
@@ -1591,7 +1595,11 @@ var writeDecisionCommand = define({
1591
1595
  composeDecisionRecord(input, actor, now()),
1592
1596
  actor
1593
1597
  );
1594
- return { conceptId: record.conceptId };
1598
+ return {
1599
+ conceptId: record.conceptId,
1600
+ action: record.action,
1601
+ supersededIds: record.supersededIds
1602
+ };
1595
1603
  }
1596
1604
  });
1597
1605
 
@@ -1690,7 +1698,7 @@ var KbRecordAlreadyExistsError = class extends BaseError {
1690
1698
  fault: "User" /* User */,
1691
1699
  retriable: false,
1692
1700
  reportToUser: true,
1693
- details: { conceptId: conceptId2 }
1701
+ details: { conceptId: conceptId2, action: "refused" }
1694
1702
  });
1695
1703
  this.conceptId = conceptId2;
1696
1704
  }
@@ -1882,13 +1890,27 @@ var KbStore = class {
1882
1890
  conceptId: conceptId2,
1883
1891
  by: actor
1884
1892
  });
1893
+ const targets = new Set(frontmatter.strauss_supersedes ?? []);
1894
+ targets.delete(conceptId2);
1895
+ const supersededIds = [];
1896
+ for (const old of targets) {
1897
+ if (await this.markSupersededRetrying(bundlePath2, old, conceptId2, actor)) {
1898
+ supersededIds.push(old);
1899
+ }
1900
+ }
1885
1901
  this.logger.info?.({
1886
1902
  operation: "kb.write",
1887
1903
  bundlePath: root,
1888
1904
  conceptId: conceptId2,
1889
1905
  anchors: frontmatter.strauss_anchors?.length ?? 0
1890
1906
  });
1891
- return { conceptId: conceptId2, frontmatter, body: input.body };
1907
+ return {
1908
+ conceptId: conceptId2,
1909
+ frontmatter,
1910
+ body: input.body,
1911
+ action: supersededIds.length ? "superseded-prior" : "created",
1912
+ supersededIds
1913
+ };
1892
1914
  }
1893
1915
  /** One record by concept id, or null when it does not exist. */
1894
1916
  async read(bundlePath2, conceptId2) {
@@ -1952,15 +1974,11 @@ var KbStore = class {
1952
1974
  async supersede(bundlePath2, conceptId2, replacementId, actor = "unknown") {
1953
1975
  const replacement = await this.read(bundlePath2, replacementId);
1954
1976
  if (!replacement) throw new KbRecordNotFoundError(replacementId);
1955
- const superseded = await this.mutate(
1977
+ const superseded = await this.markSuperseded(
1956
1978
  bundlePath2,
1957
1979
  conceptId2,
1958
- (frontmatter) => ({
1959
- ...frontmatter,
1960
- strauss_status: "superseded",
1961
- strauss_superseded_by: replacementId
1962
- }),
1963
- { operation: "supersede", by: actor, target: replacementId }
1980
+ replacementId,
1981
+ actor
1964
1982
  );
1965
1983
  await this.mutate(
1966
1984
  bundlePath2,
@@ -2124,6 +2142,42 @@ ${answer}
2124
2142
  }
2125
2143
  return result;
2126
2144
  }
2145
+ /**
2146
+ * `markSuperseded`, tolerant of the two ways it legitimately doesn't land:
2147
+ * a missing target (a broken link, legal per compose.ts) or a CAS conflict
2148
+ * from a concurrent writer touching the same target. A conflict is retried
2149
+ * a bounded number of times — each attempt re-reads the target fresh — and
2150
+ * on the last, `false` reports "not marked" rather than throwing: the
2151
+ * caller's own record is already published, so failing here would leave
2152
+ * that publish unreported instead of undone. kb_validate's existing
2153
+ * "not marked superseded" check is what surfaces the residue.
2154
+ */
2155
+ async markSupersededRetrying(bundlePath2, conceptId2, replacementId, actor, retries = 3) {
2156
+ for (let attempt = 0; attempt <= retries; attempt++) {
2157
+ try {
2158
+ await this.markSuperseded(bundlePath2, conceptId2, replacementId, actor);
2159
+ return true;
2160
+ } catch (error) {
2161
+ if (error instanceof KbRecordNotFoundError) return false;
2162
+ if (!(error instanceof KbWriteConflictError)) throw error;
2163
+ if (attempt === retries) return false;
2164
+ }
2165
+ }
2166
+ return false;
2167
+ }
2168
+ /** The one-directional half of `supersede`: marks `conceptId` superseded. */
2169
+ async markSuperseded(bundlePath2, conceptId2, replacementId, actor) {
2170
+ return this.mutate(
2171
+ bundlePath2,
2172
+ conceptId2,
2173
+ (frontmatter) => ({
2174
+ ...frontmatter,
2175
+ strauss_status: "superseded",
2176
+ strauss_superseded_by: replacementId
2177
+ }),
2178
+ { operation: "supersede", by: actor, target: replacementId }
2179
+ );
2180
+ }
2127
2181
  async mutate(bundlePath2, conceptId2, change, entry, changeBody = (body) => body) {
2128
2182
  const target = this.recordPath(bundlePath2, conceptId2);
2129
2183
  const before = await readFile3(target, "utf8").catch(() => null);
@@ -2320,4 +2374,4 @@ export {
2320
2374
  KB_DIR,
2321
2375
  KbStore
2322
2376
  };
2323
- //# sourceMappingURL=chunk-HYNAEAPM.js.map
2377
+ //# sourceMappingURL=chunk-EDH43Z7J.js.map