From 3158bfa76d3d1c0b24363792de7cc0ac5fd231cd Mon Sep 17 00:00:00 2001 From: indexzero Date: Sat, 1 Aug 2026 00:53:18 -0400 Subject: [PATCH 1/4] feat(okf/cli): ingest report counts hash-match skips (ADR-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-line ingest report now distinguishes "already in log" from "nothing found": hash-match skips (manifest/echo suppression, reason "unchanged") append "; N file(s) already up to date". Decided once: the clause is omitted entirely when no skips occurred — an empty bundle still prints the bare "0 op(s) from 0 file(s)" line. Exit codes are untouched. Co-Authored-By: Claude Fable 5 --- okf/cli/lib/run.js | 10 +++++++++- okf/test/cli.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/okf/cli/lib/run.js b/okf/cli/lib/run.js index 57239d3..c9dafe5 100644 --- a/okf/cli/lib/run.js +++ b/okf/cli/lib/run.js @@ -440,11 +440,17 @@ async function cmdIngest({ args, flags }) { }) let files = 0 let ops = 0 + let upToDate = 0 watcher.on('ops', ({ path, ops: emitted }) => { files++ ops += emitted.length console.log(`ingested ${path}: ${emitted.map((o) => o.type).join(', ') || '(no-op)'}`) }) + // ADR-003: count the hash-match skips so "0 op(s) from 0 file(s)" cannot + // mean both "nothing found" and "everything already converged". + watcher.on('skip', ({ reason }) => { + if (reason === 'unchanged') upToDate++ + }) watcher.on('warning', ({ path, message }) => process.stderr.write(`warning (${path}): ${message}\n`) ) @@ -454,7 +460,9 @@ async function cmdIngest({ args, flags }) { try { await watcher.scan() await vault.update() - console.log(`ingest complete: ${ops} op(s) from ${files} file(s)`) + // The skip clause is omitted (never "0") when no skips occurred. + const skips = upToDate > 0 ? `; ${upToDate} file(s) already up to date` : '' + console.log(`ingest complete: ${ops} op(s) from ${files} file(s)${skips}`) return 0 } finally { await watcher.close() diff --git a/okf/test/cli.test.js b/okf/test/cli.test.js index c9e21d4..5928741 100644 --- a/okf/test/cli.test.js +++ b/okf/test/cli.test.js @@ -117,6 +117,31 @@ test('ingest a hand-authored file mints a concept; materialize round-trips it wi assert.match(emitted, /A hand-authored body\./) }) +test('ingest reports hash-match skips as already up to date (ADR-003)', async () => { + const base = await tmpDir('uptodate') + const storage = join(base, 'vault') + const bundle = join(base, 'bundle') + await cli(['init', storage]) + await mkdir(bundle, { recursive: true }) + + // an empty bundle prints NO skip clause (decided once: omitted, never "0") + const empty = await cli(['ingest', storage, bundle]) + assert.equal(empty.code, 0, empty.stderr) + assert.match(empty.stdout, /^ingest complete: 0 op\(s\) from 0 file\(s\)$/m) + + await writeFile(join(bundle, 'note.md'), '---\ntype: note\n---\nConverged body.\n') + await cli(['ingest', storage, bundle]) + await cli(['materialize', storage, bundle]) // stamps _id/_rev + manifest hash + + // the swept-writer scenario (F1): every candidate hash-matches the manifest + const again = await cli(['ingest', storage, bundle]) + assert.equal(again.code, 0, again.stderr) + assert.match( + again.stdout, + /^ingest complete: 0 op\(s\) from 0 file\(s\); 1 file\(s\) already up to date$/m + ) +}) + test('wanted lists an unresolved link, ranked', async () => { const base = await tmpDir('wanted') const storage = join(base, 'vault') From 9d8dcaf4cb861573a3ad7e853b63d0740afc5f4f Mon Sep 17 00:00:00 2001 From: indexzero Date: Sat, 1 Aug 2026 00:55:10 -0400 Subject: [PATCH 2/4] feat(okf/cli): materialize exit taxonomy 0 clean / 3 pending-only / 1 failure (ADR-004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single `return report.ok ? 0 : 1` (the N3 comment) becomes the three-code taxonomy: 0 = projected everything, no pending files; 3 = projected everything owned, N foreign pending-ingest file(s) left in place, listed on stderr as `notice: pending ingest (unsaved external edit, left in place): `; 1 = real failure. Scripts treating any nonzero as failure keep the stop (3 is nonzero). Placement of the other report lists: `stale` is a notice accompanying exit 0/3 (the files are left in place by design — derived faces a human edited, not a fault of this run). `unsafe` stays failure-side (exit 1, `error:` prefix): an escapes-root path means part of the owned projection was NOT written, so "everything owned was projected" — the premise of exit 3 — does not hold, and §9 containment is a defense whose tripping should stop pipelines. SPEC §9 and the CLI README updated; the missing pending-case CLI test added alongside pins for 0 and 1. Co-Authored-By: Claude Fable 5 --- okf/SPEC.md | 11 +++++++---- okf/cli/README.md | 9 ++++++--- okf/cli/lib/run.js | 23 +++++++++++++++-------- okf/test/cli.test.js | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/okf/SPEC.md b/okf/SPEC.md index 8a0775f..725faf5 100644 --- a/okf/SPEC.md +++ b/okf/SPEC.md @@ -515,10 +515,13 @@ saga is the cautionary tale). The rules are frozen: directories are then removed. A `.md` file whose current bytes differ from its manifest entry — or that has no entry (human-authored, not yet ingested) — is **never deleted or overwritten**: it is left in place, - reported as pending-ingest (an ingest candidate, surfaced on the CLI), - and the run exits nonzero unless ingest is run first - (`materialize --force-mirror` restores unconditional mirroring for - operator use). Deletion honors the same containment checks as writes. + reported as pending-ingest (an ingest candidate, surfaced on the CLI as + a `notice:`, never an `error:`), and the run exits 3 — pending-only: + everything owned was projected; a clean run exits 0, a real failure + (I/O, corrupt vault, bad args, an unsafe path) exits 1 (ADR-004) — + unless ingest is run first (`materialize --force-mirror` restores + unconditional mirroring for operator use). Stale files (derived faces a + human edited) are likewise a notice accompanying exit 0/3. Deletion honors the same containment checks as writes. The ingest watcher treats manifest-gated reconciliation deletions as materialize-authored (in-flight-lock coordination), never as human deletes. Converged-peer I9 is judged on the emitted set; pending-ingest diff --git a/okf/cli/README.md b/okf/cli/README.md index 6e420f7..04e6657 100644 --- a/okf/cli/README.md +++ b/okf/cli/README.md @@ -67,7 +67,7 @@ pnpm add @auto-okf/cli # or run the repo-local bin: okf/cli/bin/auto-okf.js | `join ` | join an existing vault by key; `--encryption-key ` and `--metadata ` carry non-default invites; when `` is already pinned the key is optional (reopen + reprint keys) | | `add-writer [--indexer]` | append an `AddWriter` governance op; reports whether it took effect (apply rejects issuers that are not confirmed indexers, §8) | | `remove-writer ` | append a `RemoveWriter` op; revocation is causal, never positional (§8) | -| `materialize ` | project the OKF v0.1 bundle (concepts + per-dir `index.md` + root `log.md`); prints written/unchanged/removed counts; reports pending-ingest, unsafe-path and stale files on stderr; exits non-zero if a human-authored file is pending ingest (N3). `--force-mirror` restores unconditional mirroring | +| `materialize ` | project the OKF v0.1 bundle (concepts + per-dir `index.md` + root `log.md`); prints written/unchanged/removed counts; pending-ingest and stale files are `notice:` lines on stderr, unsafe paths `error:` lines. Exits 3 when everything owned was projected but human-authored file(s) are pending ingest (N3); an unsafe path is a real failure, exit 1. `--force-mirror` restores unconditional mirroring | | `ingest ` | one-shot: fold on-disk edits under `` back into the log as minimal ops (§9); `--confirm-deletes` lets a file deleted on disk emit `DeleteConcept` (off by default) | | `watch [--swarm]` | foreground ingest watcher: human edits re-enter as ops as files change; `--swarm` also joins the vault's hyperswarm topic for replication; `--confirm-deletes`, `--debounce `. No daemon — stop with Ctrl-C | | `wanted ` | print the ranked demand index (`wanted/`): `\t\t<- `, most-referenced first; `--json` for the structured rows | @@ -95,10 +95,13 @@ the log — everything that appears came from it. ## Exit codes `0` success · `1` operational error (including a governance op that apply -rejected, or a `materialize` blocked on a pending-ingest file) · `2` usage +rejected, or an unsafe path skipped during `materialize`) · `2` usage error — unknown flags and commands, missing or extra arguments, and malformed values all exit 2 (paparam parses strictly; the wrapper maps every parse -failure to 2). `-h`/`--help` prints the paparam-generated help (top-level and +failure to 2) · `3` (`materialize` only) everything owned was projected but +human-authored file(s) were left in place pending ingest — a normal +multi-writer state, listed as `notice:` lines on stderr, not a failure +(ADR-004). `-h`/`--help` prints the paparam-generated help (top-level and per command) and exits 0. ## Notes diff --git a/okf/cli/lib/run.js b/okf/cli/lib/run.js index c9dafe5..13d0a7d 100644 --- a/okf/cli/lib/run.js +++ b/okf/cli/lib/run.js @@ -9,7 +9,9 @@ // the 12 subcommands from command()/flag()/arg(), with --help|-h and all help // text generated from the declarations. // -// Exit codes: 0 success, 1 operational error, 2 usage error. paparam's +// Exit codes: 0 success, 1 operational error, 2 usage error, 3 materialize +// projected everything owned but left pending-ingest file(s) in place +// (ADR-004). paparam's // parse() returns null on any parse failure; the wrapper maps that — unknown // flag/command, extra arguments, bad flag values, and (via per-command // validate()) missing required arguments — to 2, never letting paparam pick @@ -23,7 +25,7 @@ import { Bundle, METADATA_FILE } from '@auto-okf/store' import { materialize, createIngestWatcher, wanted, compact } from '@auto-okf/faces' const TITLE = 'auto-okf — multi-writer OKF bundles on autobee (okf SPEC §13)' -const FOOTER = 'exit codes: 0 ok, 1 error, 2 usage' +const FOOTER = 'exit codes: 0 ok, 1 error, 2 usage, 3 pending-ingest only (materialize)' class UsageError extends Error {} @@ -417,17 +419,22 @@ async function cmdMaterialize({ args, flags }) { `${report.unchanged.length} unchanged, ${report.removed.length} removed` ) for (const rel of report.pendingIngest) { - process.stderr.write(`pending ingest (unsaved external edit, left in place): ${rel}\n`) + process.stderr.write(`notice: pending ingest (unsaved external edit, left in place): ${rel}\n`) } for (const u of report.unsafe || []) { - const label = typeof u === 'string' ? u : `${u.path || u.id} (${u.reason})` - process.stderr.write(`unsafe path (skipped, §9): ${label}\n`) + const label = typeof u === 'string' ? u : `${u.rel || u.path || u.id} (${u.reason})` + process.stderr.write(`error: unsafe path (skipped, §9): ${label}\n`) } for (const rel of report.stale || []) { - process.stderr.write(`stale (no longer produced, left in place): ${rel}\n`) + process.stderr.write(`notice: stale (no longer produced, left in place): ${rel}\n`) } - // N3: a pending-ingest file makes report.ok false — surface it as an error. - return report.ok ? 0 : 1 + // N3 exit taxonomy (ADR-004): an unsafe path means part of the owned + // projection was NOT written — a real failure (1). Pending-ingest files + // are a healthy multi-writer state: everything owned was projected and + // the foreign files were left in place (3). Otherwise clean (0); stale + // is a notice either way (left in place, per spec). + if ((report.unsafe || []).length > 0) return 1 + return report.ok ? 0 : 3 }) } diff --git a/okf/test/cli.test.js b/okf/test/cli.test.js index 5928741..767a76a 100644 --- a/okf/test/cli.test.js +++ b/okf/test/cli.test.js @@ -117,6 +117,38 @@ test('ingest a hand-authored file mints a concept; materialize round-trips it wi assert.match(emitted, /A hand-authored body\./) }) +test('materialize exit taxonomy: 0 clean, 3 pending-only, 1 failure (ADR-004)', async () => { + const base = await tmpDir('taxonomy') + const storage = join(base, 'vault') + const bundle = join(base, 'bundle') + await cli(['init', storage]) + + const clean = await cli(['materialize', storage, bundle]) + assert.equal(clean.code, 0, 'clean run exits 0') + + // a concurrent writer's not-yet-ingested file: healthy, not a failure + await writeFile(join(bundle, 'foreign.md'), '---\ntype: note\n---\nMid-authoring.\n') + const pending = await cli(['materialize', storage, bundle]) + assert.equal(pending.code, 3, `pending-only exits 3, stderr:\n${pending.stderr}`) + assert.match( + pending.stderr, + /^notice: pending ingest \(unsaved external edit, left in place\): foreign\.md$/m + ) + assert.match(pending.stdout, /^materialized /m, 'the owned projection still completed') + const kept = await readFile(join(bundle, 'foreign.md'), 'utf8') + assert.match(kept, /Mid-authoring\./, 'the foreign file was left in place') + + // real failure still exits 1 + const broken = await cli(['materialize', join(base, 'no-vault'), bundle]) + assert.equal(broken.code, 1) + assert.match(broken.stderr, /not a vault/) + + // once the foreign file is ingested the same bundle materializes clean + await cli(['ingest', storage, bundle]) + const after = await cli(['materialize', storage, bundle]) + assert.equal(after.code, 0, after.stderr) +}) + test('ingest reports hash-match skips as already up to date (ADR-003)', async () => { const base = await tmpDir('uptodate') const storage = join(base, 'vault') From a0ed6eab760552db7a2ef4c9a2980e354119511d Mon Sep 17 00:00:00 2001 From: indexzero Date: Sat, 1 Aug 2026 00:57:49 -0400 Subject: [PATCH 3/4] feat(okf/faces): ingest preflights maxOpBytes; the limit joins the CLI docs (ADR-005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A candidate whose body would exceed the 65,536-byte maxOpBytes bound is refused at ingest, by name, before any op for that file is appended — `error: drafts/big.md body is 71,204 bytes; maxOpBytes is 65,536` — exit 1; sibling files in the same run still ingest. The limit is a new DEFAULT_LIMITS export from @auto-okf/core (the same values apply defends with, never a duplicated constant); apply-side oversize-op stays as defense in depth. The CLI README states the limit and the remedy (split the document). The preflight boundary is body bytes vs maxOpBytes; the per-op encoding overhead on top of the body remains apply territory, so tests pin one byte over plus a comfortably-under sibling rather than a fragile exact-overhead fixture. Co-Authored-By: Claude Fable 5 --- okf/SPEC.md | 5 +++++ okf/cli/README.md | 3 +++ okf/cli/lib/run.js | 16 +++++++++++++++- okf/core/index.js | 1 + okf/core/lib/apply.js | 11 ++++++++++- okf/faces/lib/ingest.js | 12 +++++++++++- okf/test/cli.test.js | 26 ++++++++++++++++++++++++++ okf/test/faces-ingest.test.js | 26 ++++++++++++++++++++++++++ 8 files changed, 97 insertions(+), 3 deletions(-) diff --git a/okf/SPEC.md b/okf/SPEC.md index 725faf5..eb9926a 100644 --- a/okf/SPEC.md +++ b/okf/SPEC.md @@ -625,6 +625,11 @@ saga is the cautionary tale). The rules are frozen: ignored. Strip one trailing `.md`, strip any fragment (`#...`), NFC-normalize; the result keys `link/`. A `SetPath` on a concept re-runs resolution of its own relative-form edges. +- **Oversize preflight (ADR-005).** A file whose body would exceed the §3 + `maxOpBytes` limit is refused at ingest, by name, with the limit and the + actual size, before any op for that file is appended; sibling files in + the same run are unaffected. Apply's `oversize-op` reject stays + untouched — validate at the boundary, defend in depth. - Debounce, write-settle, in-flight-lock coordination with materialize. - `index.md`/`log.md` are reserved: edits to them are ignored (regenerated faces), with a single narrated log entry. diff --git a/okf/cli/README.md b/okf/cli/README.md index 04e6657..cada2ec 100644 --- a/okf/cli/README.md +++ b/okf/cli/README.md @@ -114,6 +114,9 @@ per command) and exits 0. the reason. - The encryption key is only needed at `init`/`join`; the store pins the encrypted bit in vault metadata so a reopen needs no key. +- A single body may not exceed `maxOpBytes` — 65,536 bytes; `ingest` refuses + an oversize file by name (exit 1, siblings unaffected) — split the document + into smaller linked concepts. ## Tests diff --git a/okf/cli/lib/run.js b/okf/cli/lib/run.js index 13d0a7d..95ae229 100644 --- a/okf/cli/lib/run.js +++ b/okf/cli/lib/run.js @@ -309,6 +309,12 @@ function localHex(vault) { return b4a.toString(vault.localKey, 'hex') } +/** `error: drafts/big.md body is 71,204 bytes; maxOpBytes is 65,536` (ADR-005). */ +function oversizeLine({ path, bytes, limit }) { + const n = (v) => v.toLocaleString('en-US') + return `error: ${path} body is ${n(bytes)} bytes; maxOpBytes is ${n(limit)}\n` +} + function printIdentity(vault) { console.log(`vault key: ${vault.keyHex}`) console.log(`writer key: ${localHex(vault)}`) @@ -458,6 +464,13 @@ async function cmdIngest({ args, flags }) { watcher.on('skip', ({ reason }) => { if (reason === 'unchanged') upToDate++ }) + // ADR-005: an oversize body is refused per file, before any of its ops; + // sibling files in the same run still ingest, but the run exits 1. + let oversize = 0 + watcher.on('oversize', (o) => { + oversize++ + process.stderr.write(oversizeLine(o)) + }) watcher.on('warning', ({ path, message }) => process.stderr.write(`warning (${path}): ${message}\n`) ) @@ -470,7 +483,7 @@ async function cmdIngest({ args, flags }) { // The skip clause is omitted (never "0") when no skips occurred. const skips = upToDate > 0 ? `; ${upToDate} file(s) already up to date` : '' console.log(`ingest complete: ${ops} op(s) from ${files} file(s)${skips}`) - return 0 + return oversize > 0 ? 1 : 0 } finally { await watcher.close() } @@ -495,6 +508,7 @@ async function cmdWatch({ args, flags }) { watcher.on('ops', ({ path, ops }) => { console.log(`ingested ${path}: ${ops.map((o) => o.type).join(', ') || '(no-op)'}`) }) + watcher.on('oversize', (o) => process.stderr.write(oversizeLine(o))) watcher.on('warning', (w) => process.stderr.write(`warning: ${w && w.message ? w.message : JSON.stringify(w)}\n`) ) diff --git a/okf/core/index.js b/okf/core/index.js index 053aa37..a04093a 100644 --- a/okf/core/index.js +++ b/okf/core/index.js @@ -20,6 +20,7 @@ export { } from './lib/ops.js' export { createApply, + DEFAULT_LIMITS, normalizePath, casefoldPath, validatePath, diff --git a/okf/core/lib/apply.js b/okf/core/lib/apply.js index 03264b9..2b49b58 100644 --- a/okf/core/lib/apply.js +++ b/okf/core/lib/apply.js @@ -41,9 +41,18 @@ import { } from './ops.js' import { extractLinks, dirOf } from './links.js' +// §3 default limits, exported so boundary layers (faces ingest, ADR-005) +// can preflight against the same values apply defends with — never a +// duplicated constant. +export const DEFAULT_LIMITS = Object.freeze({ + maxOpBytes: 65536, + maxPathBytes: 1024, + maxPathDepth: 64 +}) + const DEFAULTS = { // §3 limits: fixed check order, single recorded reason. - limits: { maxOpBytes: 65536, maxPathBytes: 1024, maxPathDepth: 64 }, + limits: DEFAULT_LIMITS, // §8: unacked writers use a fixed default ceiling (64) until admitted; // H_max = 512 held ops per writer. defaultCeiling is the admitted-writer // ceiling when no SetWriterPolicy has set one (spec-silent; pinned here diff --git a/okf/faces/lib/ingest.js b/okf/faces/lib/ingest.js index 20dbea1..da6fd9a 100644 --- a/okf/faces/lib/ingest.js +++ b/okf/faces/lib/ingest.js @@ -30,7 +30,7 @@ import { EventEmitter } from 'node:events' import { watch } from 'node:fs' import { readFile } from 'node:fs/promises' import { join, sep } from 'node:path' -import { keyspace as K, canonical } from '@auto-okf/core' +import { keyspace as K, canonical, DEFAULT_LIMITS } from '@auto-okf/core' import { parseNote, yamlToValues } from './yaml.js' import { rootRegistry, @@ -251,6 +251,16 @@ export class IngestWatcher extends EventEmitter { const { fm, body, warnings } = parseNote(raw.toString('utf8')) for (const wmsg of warnings) this.emit('warning', { path: rel, message: wmsg }) + // §9 oversize preflight (ADR-005): a body that would exceed the §3 + // maxOpBytes limit is refused HERE, by name, before any op for this + // file is appended — apply's oversize-op reject stays as defense in + // depth. The file stays pending; sibling files are unaffected. + const bodyBytes = Buffer.byteLength(nfc(body), 'utf8') + if (bodyBytes > DEFAULT_LIMITS.maxOpBytes) { + this.emit('oversize', { path: rel, bytes: bodyBytes, limit: DEFAULT_LIMITS.maxOpBytes }) + return [] + } + const { ops, established } = await this._ingestParsed(rel, fm || {}, body) if (!established) { diff --git a/okf/test/cli.test.js b/okf/test/cli.test.js index 767a76a..82d0036 100644 --- a/okf/test/cli.test.js +++ b/okf/test/cli.test.js @@ -174,6 +174,32 @@ test('ingest reports hash-match skips as already up to date (ADR-003)', async () ) }) +test('ingest preflights maxOpBytes: oversize file named, siblings unaffected (ADR-005)', async () => { + const base = await tmpDir('oversize') + const storage = join(base, 'vault') + const bundle = join(base, 'bundle') + await cli(['init', storage]) + await mkdir(bundle, { recursive: true }) + await writeFile(join(bundle, 'big.md'), `---\ntype: note\n---\n${'x'.repeat(70000)}\n`) + await writeFile(join(bundle, 'small.md'), '---\ntype: note\n---\nComfortably under the limit.\n') + + const ing = await cli(['ingest', storage, bundle]) + assert.equal(ing.code, 1, 'an oversize preflight refusal is an error exit') + assert.match(ing.stderr, /^error: big\.md body is 70,000 bytes; maxOpBytes is 65,536$/m) + assert.match(ing.stdout, /ingested small\.md: create-concept/, 'the sibling still ingested') + assert.doesNotMatch(ing.stdout, /ingested big\.md/) + + // zero ops were appended for the oversize file: no concept claims its path + const v = await Bundle.open(storage) + try { + await v.update() + assert.equal(await v.val(K.path('big')), undefined) + assert.notEqual(await v.val(K.path('small')), undefined) + } finally { + await v.close() + } +}) + test('wanted lists an unresolved link, ranked', async () => { const base = await tmpDir('wanted') const storage = join(base, 'vault') diff --git a/okf/test/faces-ingest.test.js b/okf/test/faces-ingest.test.js index 1697228..51a263b 100644 --- a/okf/test/faces-ingest.test.js +++ b/okf/test/faces-ingest.test.js @@ -186,3 +186,29 @@ test('adopting an unknown _id creates the concept (generation-cycle contract)', await w.close() await v.close() }) + +test('oversize body preflight: named refusal, zero ops, siblings unaffected (ADR-005)', async () => { + const v = await Bundle.init(await tmpDir('vault')) + const dir = await tmpDir('bundle') + await materialize(v, dir) // establish the manifest + reserved faces + + // One byte over the limit, plus a comfortably-under sibling. The preflight + // boundary is body bytes vs maxOpBytes; the per-op encoding overhead on top + // of the body stays apply's defense-in-depth territory (oversize-op). + const limit = 65536 + await writeFile(join(dir, 'big.md'), `---\ntype: note\n---\n${'x'.repeat(limit + 1)}\n`) + await writeFile(join(dir, 'small.md'), `---\ntype: note\n---\n${'y'.repeat(60000)}\n`) + + const w = await createIngestWatcher(v, dir, { watch: false }) + const oversize = [] + w.on('oversize', (e) => oversize.push(e)) + const ops = await w.scan() + + assert.deepEqual(oversize, [{ path: 'big.md', bytes: limit + 1, limit }]) + assert.ok(!ops.some((o) => o.payload && o.payload.path === 'big'), 'zero ops for the oversize file') + await v.update() + assert.equal(await v.val(K.path('big')), undefined, 'no concept claims the oversize path') + assert.notEqual(await v.val(K.path('small')), undefined, 'the sibling still ingested') + await w.close() + await v.close() +}) From a86f247ef82a4613a94f76ee7e2a07f2b548bf18 Mon Sep 17 00:00:00 2001 From: indexzero Date: Sat, 1 Aug 2026 01:00:28 -0400 Subject: [PATCH 4/4] feat(cli): twin CLIs refuse each other's vaults by marker file (ADR-006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each CLI checks for the sibling system's marker on every vault-open path (withVault, init, join, watch) and refuses pointedly, naming the right tool: `error: is an auto-okf vault (okf-vault.json present); use auto-okf, not autovault-ld` — and symmetrically for vault-ld.json in auto-okf. One stat on the open path; exit 1; storage untouched. The marker filenames are stable contract between the twin systems, so each CLI carries the sibling name as a constant rather than importing across systems. The one cross-system change in this batch (the vault-ld CLI gains the symmetric check + test). Co-Authored-By: Claude Fable 5 --- okf/cli/README.md | 4 ++++ okf/cli/lib/run.js | 28 ++++++++++++++++++++++++++++ okf/test/cli.test.js | 25 ++++++++++++++++++++++++- test/cli.test.js | 25 ++++++++++++++++++++++++- vault-ld/cli/README.md | 4 ++++ vault-ld/cli/lib/run.js | 28 ++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 2 deletions(-) diff --git a/okf/cli/README.md b/okf/cli/README.md index cada2ec..fba5428 100644 --- a/okf/cli/README.md +++ b/okf/cli/README.md @@ -117,6 +117,10 @@ per command) and exits 0. - A single body may not exceed `maxOpBytes` — 65,536 bytes; `ingest` refuses an oversize file by name (exit 1, siblings unaffected) — split the document into smaller linked concepts. +- `auto-okf` refuses vault-ld storage by marker file (`vault-ld.json`), + naming the right tool (exit 1, storage untouched); `autovault-ld` refuses + `okf-vault.json` symmetrically — the twin CLIs never open each other's + vaults (ADR-006). ## Tests diff --git a/okf/cli/lib/run.js b/okf/cli/lib/run.js index 95ae229..38c368a 100644 --- a/okf/cli/lib/run.js +++ b/okf/cli/lib/run.js @@ -29,6 +29,24 @@ const FOOTER = 'exit codes: 0 ok, 1 error, 2 usage, 3 pending-ingest only (mater class UsageError extends Error {} +// ADR-006 wrong-tool guardrail: the sibling system's marker file is stable +// contract between the twin CLIs; auto-okf refuses vault-ld storage +// pointedly (naming the right tool) instead of opening it. +const SIBLING_MARKER = 'vault-ld.json' + +class WrongToolError extends Error {} + +async function refuseSiblingVault(dir) { + try { + await access(join(dir, SIBLING_MARKER)) + } catch { + return + } + throw new WrongToolError( + `${dir} is a vault-ld vault (${SIBLING_MARKER} present); use autovault-ld, not auto-okf` + ) +} + /** Run the CLI. Returns the process exit code. */ export async function run(argv) { const { root, captured } = buildCli() @@ -70,6 +88,12 @@ export async function run(argv) { process.stderr.write(`auto-okf ${parsed.name}: ${err.message}\n\n${parsed.help()}`) return 2 } + if (err instanceof WrongToolError) { + // ADR-006: the refusal shape is pinned — `error: is a … vault + // ( present); use , not `. + process.stderr.write(`error: ${err.message}\n`) + return 1 + } process.stderr.write(`auto-okf ${parsed.name}: ${err && err.message ? err.message : err}\n`) return 1 } @@ -292,6 +316,7 @@ async function isPinned(dir) { /** Open an EXISTING vault (pinned dir) and close it after fn. */ async function withVault(dir, opts, fn) { + await refuseSiblingVault(dir) if (!(await isPinned(dir))) { throw new Error( `'${dir}' is not a vault (no ${METADATA_FILE}); run \`auto-okf init\` or \`auto-okf join\` first` @@ -330,6 +355,7 @@ async function rejectionFor(vault, seq) { async function cmdInit({ args, flags }) { const dir = args.dir + await refuseSiblingVault(dir) if (await isPinned(dir)) { throw new Error(`'${dir}' is already a vault (${METADATA_FILE} exists)`) } @@ -351,6 +377,7 @@ async function cmdInit({ args, flags }) { async function cmdJoin({ args, flags }) { const { dir, vaultKey: key } = args + await refuseSiblingVault(dir) const opts = {} if (flags.encryptionKey) { opts.encryptionKey = hexKey(flags.encryptionKey, '--encryption-key') @@ -492,6 +519,7 @@ async function cmdIngest({ args, flags }) { async function cmdWatch({ args, flags }) { const { dir, bundleDir } = args + await refuseSiblingVault(dir) if (!(await isPinned(dir))) { throw new Error( `'${dir}' is not a vault (no ${METADATA_FILE}); run \`auto-okf init\` or \`auto-okf join\` first` diff --git a/okf/test/cli.test.js b/okf/test/cli.test.js index 82d0036..9a0d4fd 100644 --- a/okf/test/cli.test.js +++ b/okf/test/cli.test.js @@ -7,7 +7,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { execFile, spawn } from 'node:child_process' -import { mkdir, readFile, writeFile, access } from 'node:fs/promises' +import { mkdir, readdir, readFile, writeFile, access } from 'node:fs/promises' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { Bundle } from '../store/index.js' @@ -117,6 +117,29 @@ test('ingest a hand-authored file mints a concept; materialize round-trips it wi assert.match(emitted, /A hand-authored body\./) }) +test('refuses a vault-ld vault by marker, naming the right tool (ADR-006)', async () => { + const dir = join(await tmpDir('wrong-tool'), 'vault') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'vault-ld.json'), '{"vaultKey":"stub"}\n') + + const attempts = [ + ['flags', dir], + ['init', dir], + ['join', dir, 'f'.repeat(64)], + ['materialize', dir, dir + '-face'] + ] + for (const args of attempts) { + const res = await cli(args) + assert.equal(res.code, 1, args.join(' ')) + assert.match( + res.stderr, + /^error: .* is a vault-ld vault \(vault-ld\.json present\); use autovault-ld, not auto-okf$/m, + args.join(' ') + ) + } + assert.deepEqual(await readdir(dir), ['vault-ld.json'], 'storage untouched') +}) + test('materialize exit taxonomy: 0 clean, 3 pending-only, 1 failure (ADR-004)', async () => { const base = await tmpDir('taxonomy') const storage = join(base, 'vault') diff --git a/test/cli.test.js b/test/cli.test.js index d51bf86..950416b 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -5,7 +5,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { execFile, spawn } from 'node:child_process' -import { mkdir, readFile, access, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, access, writeFile } from 'node:fs/promises' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { Vault } from '../vault-ld/store/index.js' @@ -76,6 +76,29 @@ test('commands on unpinned storage fail with guidance, not junk vaults', async ( } }) +test('refuses an auto-okf vault by marker, naming the right tool (okf ADR-006)', async () => { + const dir = join(await tmpDir('wrong-tool'), 'vault') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'okf-vault.json'), '{"vaultKey":"stub"}\n') + + const attempts = [ + ['flags', dir], + ['init', dir], + ['join', dir, 'f'.repeat(64)], + ['materialize', dir, dir + '-face'] + ] + for (const args of attempts) { + const res = await cli(args) + assert.equal(res.code, 1, args.join(' ')) + assert.match( + res.stderr, + /^error: .* is an auto-okf vault \(okf-vault\.json present\); use auto-okf, not autovault-ld$/m, + args.join(' ') + ) + } + assert.deepEqual(await readdir(dir), ['okf-vault.json'], 'storage untouched') +}) + test('join + add-writer + remove-writer round trip (§8.0/§8.3 plumbing)', async () => { const base = await tmpDir('writers') const origin = join(base, 'origin') diff --git a/vault-ld/cli/README.md b/vault-ld/cli/README.md index 80c1b2a..66e18a2 100644 --- a/vault-ld/cli/README.md +++ b/vault-ld/cli/README.md @@ -76,6 +76,10 @@ for the root or any subcommand and exits `0`. - The encryption key is only needed at `init`/`join`; autobee pins it in the local core's user data and the metadata pin restores the encrypted bit on reopen. +- `autovault-ld` refuses auto-okf storage by marker file (`okf-vault.json`), + naming the right tool (exit 1, storage untouched); `auto-okf` refuses + `vault-ld.json` symmetrically — the twin CLIs never open each other's + vaults (okf ADR-006). ## Tests diff --git a/vault-ld/cli/lib/run.js b/vault-ld/cli/lib/run.js index 2b04215..aeaba57 100644 --- a/vault-ld/cli/lib/run.js +++ b/vault-ld/cli/lib/run.js @@ -23,6 +23,24 @@ const FOOTER = 'exit codes: 0 ok, 1 error, 2 usage' class UsageError extends Error {} +// Wrong-tool guardrail (okf ADR-006): the sibling system's marker file is +// stable contract between the twin CLIs; autovault-ld refuses auto-okf +// storage pointedly (naming the right tool) instead of opening it. +const SIBLING_MARKER = 'okf-vault.json' + +class WrongToolError extends Error {} + +async function refuseSiblingVault(storage) { + try { + await access(join(storage, SIBLING_MARKER)) + } catch { + return + } + throw new WrongToolError( + `${storage} is an auto-okf vault (${SIBLING_MARKER} present); use auto-okf, not autovault-ld` + ) +} + /** Run the CLI. Returns the process exit code. */ export async function run(argv) { let code = 0 @@ -34,6 +52,13 @@ export async function run(argv) { try { code = await fn(args, flags) } catch (err) { + if (err instanceof WrongToolError) { + // ADR-006: the refusal shape is pinned — `error: is a … vault + // ( present); use , not `. + process.stderr.write(`error: ${err.message}\n`) + code = 1 + return + } process.stderr.write(`autovault-ld ${name}: ${err && err.message ? err.message : err}\n`) code = err instanceof UsageError ? 2 : 1 } @@ -242,6 +267,7 @@ async function isPinned(storage) { /** Open an EXISTING vault (pinned storage) and close it after fn. */ async function withVault(storage, opts, fn) { + await refuseSiblingVault(storage) if (!opts.key && !(await isPinned(storage))) { throw new Error(`'${storage}' is not a vault (no ${METADATA_FILE}); run \`autovault-ld init\` or \`autovault-ld join\` first`) } @@ -262,6 +288,7 @@ function printIdentity(vault) { async function cmdInit(args, flags) { const { storage } = args + await refuseSiblingVault(storage) if (await isPinned(storage)) { throw new Error(`'${storage}' is already a vault (${METADATA_FILE} exists)`) } @@ -388,6 +415,7 @@ async function cmdFlags(args, flags) { async function cmdWatch(args, flags) { const { storage, dir } = args + await refuseSiblingVault(storage) if (!(await isPinned(storage))) { throw new Error(`'${storage}' is not a vault (no ${METADATA_FILE}); run \`autovault-ld init\` or \`autovault-ld join\` first`) }