Skip to content

fix(knowledge): a write from an out-of-date read silently erases a peer's page — knowledge_read returns no digest and knowledge_record checks none #205

Description

@drewstone

Version checked

@tangle-network/agent-knowledge 15.0.2 (tag v15.0.2, 1b1408e), the version discovery-lab installs at discovery-lab@3ed49be3. Re-read at origin/main (a7fb5fc7, package version 15.0.3): src/knowledge-tools.ts, src/proposals.ts, src/file-transaction.ts and src/mutation-lock.ts are identical at the two refs (git diff --stat v15.0.2 origin/main -- <those four> prints nothing), so every line below holds at both.

Defect

Concurrency control at the file level is correct, and the layer above it has no version check.

  • One cross-process lock per store serializes every write (src/mutation-lock.ts:18, :141-164). Two writers never tear a file.
  • knowledge_read returns pageId, origin, path, title and text (src/knowledge-tools.ts:121-149). It returns no digest, so a writer cannot name the version it read.
  • knowledge_record (knowledge-tools.ts:151-164) calls applyKnowledgeWriteBlocks (src/proposals.ts:42-87), which replaces whole files from the ---FILE--- blocks. Nothing compares an incoming page with the version on disk, and the optional intake gate does not either — it refuses unrelated near-duplicates and dangling citations (src/write-intake.ts:32-38).
  • A finished transaction's directory, holding its manifest and its before-and-after snapshots, is deleted (src/file-transaction.ts:425). The overwritten bytes then exist nowhere.

So a writer that edits from a stale read erases a peer's page, and both calls report success.

The store already holds the machinery: contentHash computes the proposal purpose (proposals.ts:49), and each transaction entry records beforeHash and afterHash (file-transaction.ts:42-51). mergeClaimLedger shows the pattern that works — it holds the lock across read, merge and write, and its merge is monotone (src/kb-store.ts:98-115, :370-389; src/claim-ledger.ts:1-19). The page tools do not use it.

Measured

Synthetic probe against the installed 15.0.2 build, on a temporary store under /tmp that is deleted afterwards. No real store, run, or session content. Two tool sets with distinct actorIds over one namespace, plus two throwaway OS processes for the race.

P1, two processes writing one page id at one instant:

agent-knowledge 15.0.2
P1 writer A {"actorId":"proc-A","ok":true,"startedAt":…153705,"finishedAt":…154086,"result":{"written":["pages/convention.md"],"warnings":[]}}
P1 writer B {"actorId":"proc-B","ok":true,"startedAt":…153601,"finishedAt":…154371,"result":{"written":["pages/convention.md"],"warnings":[]}}
P1 final page: "---\nid: convention\n---\nCount permutations of length n+1 (director B).\n"
P1 .agent-knowledge after both writes: ["file-transactions/","mutation-epoch.json","sources.json"]

The two calls overlapped for 381 ms, both succeeded, and A's text survives nowhere: file-transactions/ is empty.

P2, the stale-base overwrite. A writes v0, A reads it, B replaces it with a counterexample, A writes its edit of the version it read:

P2 A read before B wrote: "v0: upper bound 12 (seed)."
P2 A stale write result: {"written":["pages/bound.md"],"warnings":[]}
P2 final page: "---\nid: bound\n---\nv0: upper bound 12 (seed). Confirmed for n<=6 (director A, edited from its read of v0).\n"
P2 is B's counterexample anywhere in the store? 13 files; false

P3, the one path that preserves disagreement today — a separate page with contradicts, which search returns beside the page it disputes:

P3 search hits: [{"pageId":"bound-dissent","origin":"here"},{"pageId":"bound","origin":"here"}]
P3 dissent page frontmatter contradicts: "contradicts: [bound]"

Probe source is in the findings linked below.

Consequence for N directors on one problem

Discovery Lab runs N research directors under one play root. They share exactly one namespace, <root>/<runId>/knowledge-base (src/run-scoped.ts:109-110), and it is their only durable shared surface: Runtime's peer mail does not reach a director at all (tangle-network/agent-runtime#1172), and the root hears from a director only when it settles. A refuted bound is exactly the page two directors edit at once.

Today the losing director's refutation leaves the page, the search index, the transaction history and every receipt at the same moment, and both directors were told written. The play then continues on a bound that one of its own directors had already refuted, with nothing in the store recording that it happened. Adding directors makes this more likely, not less.

#151 lists "Preserve all sides of a dispute; no last-write-wins truth overwrite" as done. That holds for a repeated id across origins and for the contradicts relation (P3). It does not hold for two writers on one page id in one store, which is the case N directors actually produce.

Correct behavior

  1. knowledge_read returns the page's content digest beside its text.
  2. A knowledge_record ---FILE--- block may declare the digest it edited.
  3. Under the lock the write already holds (proposals.ts:51-86), a block whose declared digest no longer matches the page on disk is refused, and the refusal names the page and the current digest. A block with no declared digest keeps today's behavior for a new page, so existing callers are unaffected.

Knowledge should prescribe no resolution beyond the refusal. The agent then merges, retries, or records dissent as a separate page with contradicts — P3 shows that path already works and that search returns both sides.

The write's author should be recorded at the same time; that is #206.

Findings

https://github.com/tangle-network/discovery/blob/195852cc3a723511d13e6302791f08da08eeb7fb/docs/research/169-collaboration-primitives.md

Serves tangle-network/discovery#169 (map tangle-network/discovery#136). Related: #151, #180, #200.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions