diff --git a/okf/SPEC.md b/okf/SPEC.md
index 8a0775f..eb9926a 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
@@ -622,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 6e420f7..fba5428 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
@@ -111,6 +114,13 @@ 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.
+- `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 57239d3..38c368a 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,10 +25,28 @@ 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 {}
+// 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()
@@ -68,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
}
@@ -290,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`
@@ -307,6 +334,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)}`)
@@ -322,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)`)
}
@@ -343,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')
@@ -417,17 +452,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
})
}
@@ -440,11 +480,24 @@ 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++
+ })
+ // 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`)
)
@@ -454,8 +507,10 @@ async function cmdIngest({ args, flags }) {
try {
await watcher.scan()
await vault.update()
- console.log(`ingest complete: ${ops} op(s) from ${files} file(s)`)
- return 0
+ // 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 oversize > 0 ? 1 : 0
} finally {
await watcher.close()
}
@@ -464,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`
@@ -480,6 +536,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 c9e21d4..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,112 @@ 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')
+ 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')
+ 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('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()
+})
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`)
}