Skip to content

offload-v1: NAS offsite + offload, security-audited#126

Merged
mbertschler merged 119 commits into
mainfrom
offload-v1
Jun 20, 2026
Merged

offload-v1: NAS offsite + offload, security-audited#126
mbertschler merged 119 commits into
mainfrom
offload-v1

Conversation

@mbertschler

Copy link
Copy Markdown
Owner

Overnight build of the squirrel offload-v1 feature set (NAS offsite + offload), followed by a four-auditor security audit focused on the append-only / never-delete-indexed-data guarantee, and fixing PRs for every finding. Nothing here has touched main — this PR is for your review. All work landed on offload-v1 via --merge commits (no squash/rebase).

How to review

Each sub-PR below was merged into offload-v1 only after: CI green (build/vet/test + golangci-lint) → GitHub Copilot review addressed → a code-reviewer standards pass (comment policy, public-repo genericness, conventions) addressed. The four deletion-gating PRs additionally got dedicated adversarial soundness re-audits.

Feature PRs (in dependency order)

PR What Reviews
#96 config: rclone crypt overlay for destinations Copilot ✓ (preflight-scope fix), standards ✓
#97 store: contents/files split, durability-vector + fingerprint tables (migrations v14–v16) Copilot ✓ (race-safe content insert, statement-level watermark monotonicity), standards ✓ (migration data-safety clean)
#98 sync: typed destination handlers + kopia Copilot ✓ (password-env dedupe), standards ✓ — caught kopia persisting the repo password to a plaintext sidecar; fixed with --no-persist-credentials
#99 syncproto/agent: verbatim origin propagation + durability metadata exchange Copilot ✓, standards ✓
#100 sync: advance the durability vector on verified pushes Copilot ✓ (gate on FinishErr), standards ✓
#101 offload command (durability-gated deletion) Copilot ✓ (dot-collapse + negative-age fail-closed), standards ✓ — adversarial review confirmed os.Root confinement + inode-pinned re-hash
#102 sync: content-addressed, append-only offsite layout (migrations v17–v18) Copilot ✓, standards ✓ (transactional landing, append-only verbs clean)
#116 scan-back fingerprint capture + verify command Copilot ✓, standards ✓ — flagged S3 multipart ETag reachability → follow-up #118

Audit-fix PRs

PR Closes (by number) Notes
#117 #111 db-restore, #112 pre-migration snapshots reversible restore + WAL-ordering; pre-migration snapshots exempt from rotation
#119 #105 origin self-attribution (critical), #106 Transfer overwrite (high); addresses #110 a/b/c 110d (per-peer tokens) deliberately left open
#120 #115 gate freshness (critical), #103 over-advance (critical), #108 kopia depth, #109 method-provenance see "Decisions to review" — a re-audit caught a wedge here that I fixed before merging
#123 addresses #104 pull-scoping exploitable part closed by #120; provenance-tagging residual left open
#124 #107 content-addressed upload + indexer integrity (high) stat-after-hash; refuse recording drifted bytes
#125 addresses #113 schema triggers, #114 robustness (migration v21) contents immutability triggers; run-gate symmetry; FinishHookRun guard; blake3 silent-degrade → downgrade-only; config↔DB path check; kopia --init gate

Audit outcome

The append-only guarantee holds on the local/bucket/store side (zero DELETE FROM; copy-only rclone verbs; --backup-dir on every overwriting push; every prior SAFETY-AUDIT finding genuinely fixed). The risk was concentrated in durability-claim soundness (the thing that gates offload deletion) and the peer-receiver write path. All four criticals (#103, #105, #106, #115) and both highs (#107, #108) are fixed and merged.

Decisions to review (your call)

  1. offload: make the durability gate sound (freshness + method provenance + snapshot-pinned advance) #120 relayed-freshness trust model (most important). Offload of a peer-relayed offsite (one the NAS pushes to, the laptop never does) now gates on a freshness coordinate carried over the durability-pull wire — i.e. the laptop trusts the NAS's recorded push-freshness for that target. This is exactly design §3's "the laptop trusts the NAS's recorded verification evidence" boundary, and it fails closed (no evidence → refuse). A first re-audit found that without this, the gate wedged (offsite offload became a permanent no-op); the wire-freshness fix un-wedged it, and a second re-audit returned MERGE-READY. Worth your eyes since it defines what the laptop trusts a peer about.
  2. kopia first-use now requires squirrel sync --init (no more silent auto-create on connect-fail). The agent/scheduler never sets --init, so the first kopia sync must be bootstrapped interactively once. Intended (err-toward-refusing), documented in flag help.
  3. Peer-relayed targets with no pulled evidence are refused (held out of the gate). Safe direction; the wire-freshness mechanism is how they become offload-eligible.

Deferred (open issues, tracked for you — not fixed tonight)

Infra side (separate repo)

Draft mbertschler/infra#18 shows the end-state nas.toml/squirrel.toml for kreisel against this CLI surface — blocked on this PR, no live changes to the NAS or any bucket. It surfaced the two config gaps fixed in #122 (s3 storage_class, sftp host-key validation).

Schema

Migrations v14 → v21 (all additive after v13; schema.sql regenerated, golden test green). Since you're the only user and OK re-migrating, these can be collapsed before a public release if desired.

Note

GitHub Copilot auto-review appeared rate-limited after ~16 PRs; #125 (final) merged on a clean standards review + green CI without a Copilot pass. Everything else got both.

An optional [destinations.<name>.crypt] table declares client-side
encryption via rclone's crypt overlay: password (required) and
password2 (salt, optional), both rclone-obscured and accepting the
same literal / { env = "VAR" } forms as destination credentials.
A crypt destination renders two rclone.conf sections — the underlying
remote unchanged plus a crypt remote wrapping it at the destination
root, with filename encryption fixed off (contents only).

type=local is rejected (no rclone remote to wrap), unknown crypt
fields fail loudly like everywhere else in the config, and a sibling
destination occupying the <name>-crypt section name is refused.
Transfers, backup-dir, and the index ride-along for a crypt
destination go through <name>-crypt:, whose remote line already
carries the destination root; the three URI builders now share
remoteSubpathURI.

rclone crypt remotes expose no content hashes, so the default
--checksum --hash blake3 verification cannot pass through the
overlay. effectiveShallow downgrades such transfers to rclone's
size+mtime comparison (the same path --shallow takes), the runs row
records them as shallow so the audit trail stays honest, and a
warning on the report surfaces the fallback unless the operator
already asked for --shallow.
A crypt destination forces shallow comparison, so requiring the blake3-
capable rclone floor for it blocks crypt-only setups on older rclone for
a flag the run never passes. EnsureMinVersion callers now derive the
preflight from the actual targets: ShallowForPairs over the invocation's
pairs (sync, agent scheduler, desktop trigger) or EffectiveShallow for
the single restore destination.
…rypt doc

The section renderer always ends with a newline (tests pin it); the doc
claimed the opposite. The resolveCrypt local-rejection rationale already
lives in its error message.
config/sync: optional crypt encryption overlay for destinations
The core reshape behind the offload feature: content becomes the
entity, paths become observations of it.

- contents (v14): one append-only row per BLAKE3 digest carrying
  size_bytes and the content-level origin (origin_node_id NULL =
  introduced locally; origin_run_id is in the origin node's run space,
  so deliberately not a local FK). The files_blake3_immutable trigger
  is dropped - the id<->hash binding is immutable by construction.
- files (v14): rekeyed to PK (folder_id, name, content_id); blake3 and
  size_bytes move to contents; the status CHECK gains 'offloaded'
  (intentional sibling of 'missing': current content whose local bytes
  were deliberately removed). The one-live-row-per-path partial unique
  index survives unchanged.
- runs (v15): kind CHECK gains 'offload', joining index/audit in the
  destination-NULL branch.
- destination_run_ids (+history) and remote_objects (v16): the
  per-destination durability version vector and the per-upload offsite
  fingerprint table; accessors land separately.

Backfill maps each distinct hash to one contents row, taking size and
origin from the files row with the earliest first_seen_run_id. The old
source_* columns were per-observation sender attribution while origin_*
is first-introduction provenance, so the earliest observation is the
closest available approximation.

Consumers follow the new shape: FileRow gains ContentID and renames
Source* to Origin*, Upsert resolves content rows (recording prov as
origin only for never-seen hashes), and ListPresentBySource becomes
ListPresentByOrigin. Peer-sync keeps its current negotiation semantics,
now reading content origin instead of row attribution; tests that
relied on relabeling an existing row's attribution are reworked to
introduce peer content the way a receiver's /close does.
destination_run_ids is the durability version vector: per (volume,
destination, origin node), the highest origin-space run id known
durable on that destination. Content with origin (N, r) is durable on
a destination iff the vector's component for N is >= r. The upsert is
monotonic - a backwards move is refused with a DestinationRewindError
unless allowRewind is passed - and every advance appends a history row
in the same transaction, mirroring the peer-sync watermark store. The
rewind sentinel is shared with peer-sync (its message loses the
peer-sync prefix), so errors.Is(err, ErrWatermarkRewind) matches both.

remote_objects records the provider checksum captured at upload time
for destinations whose bytes can't be cheaply re-read; verification
re-fetches the provider value and compares strings, then stamps
verified_at_ns. Inserts refuse duplicates so the recorded fingerprint
can't be silently replaced.

No callers yet - the offload and offsite upload flows wire these up in
later PRs.
Drives a populated v13 fixture (duplicate content with mixed
attribution, a peer-sourced live row, a superseded predecessor, a
missing row) through the v14-v16 chain and pins the reshape: row
counts, the hash<->content mapping with its earliest-observation
size/origin backfill, preserved statuses and run stamps, duplicate
detection across the shared content row, the surviving partial unique
index, the dropped immutability trigger, the widened runs kind CHECK,
and the new watermark store's rewind refusal against the migrated
database.
An offloaded row's on-disk absence is intentional: MarkMissing only
flips 'present' rows, so a re-index leaves the row offloaded and
reports zero missing. When the file reappears with its recorded
content, the next index run re-hashes it (the shallow shortcut only
applies to 'present' rows) and the upsert flips offloaded back to
present, preserving first_seen_run_id.

Both behaviours fall out of the existing indexer machinery; these
tests pin them ahead of the offload command landing.
…onotonicity

getOrCreateContentTx now errors when a known digest is observed with a
different size (corruption or a mis-hashing caller), and inserts with
ON CONFLICT DO NOTHING + re-lookup so a concurrent writer of the same
digest cannot fail the transaction. UpsertDestinationRunID enforces
monotonicity inside the conditional DO UPDATE itself, so a racing
advance can never be regressed by a stale writer; the rewind error is
derived from the zero-rows outcome. MarkRemoteObjectVerified wraps its
RowsAffected error like the surrounding methods.
store: split files into contents + files observations (schema v14–v16)
A kopia destination is a local-filesystem repository driven by the
kopia binary rather than an rclone remote: root is the repository
path and the only parameter is the required repository password
(literal or { env = "VAR" }, resolved like every other secret).
It renders no rclone.conf section, so the password stays out of
that file, and a crypt block is rejected because kopia encrypts
its repository itself.
GetOrCreateOriginNode maps a wire-carried origin node name to a local
nodes row (names are the cross-node identity; ids differ per node),
creating placeholder-endpoint rows for origins this host has never
peered with. ValidNodeName exposes the name rule so the protocol layer
can refuse bad names up front. ContentIntroductionRunID returns the
earliest first_seen_run_id of a content in a volume — the origin-run
coordinate the sender materialises for locally-introduced content.
Computes the per-origin-node maximum origin run over a volume's present
files (NULL content origin maps to the self node at the observation's
first_seen_run_id; reserved sync subtrees are excluded because they
never travel to a destination) and advances each component through the
monotonic UpsertDestinationRunID. A component already recorded higher
stays — componentwise max, the version-vector join. Also adds
ListVolumeDestinationRunIDs for the peer durability endpoint.
A destination's behavior is now owned by a curated, type-determined
Handler: the rclone handler wraps the existing bucket Sync and the
peer handler wraps the SyncNode handshake, both unchanged in
behavior. RunPair resolves the handler from a Tools bundle instead
of dispatching on the Pair slots directly.

Each push now also reports a typed VerifyResult on the Report:
which comparison backed the transfer (blake3, size+mtime, or the
receiver-side peer re-hash) and the tool's counts. The verified
flag is unexported and the Handler interface is sealed, so a
positive durability claim can only be minted by the curated
handlers in this package — the hook mechanism stays exit-code-only
by construction, pinned by an external-package test.
IndexEntry gains (origin_node, origin_run): the content's global origin
coordinate — origin node NAME plus that node's run id at introduction —
carried verbatim across every hop, never relabelled to the immediate
sender. The supersede/conflict disposition docs now describe delivery-
based classification (who materialised the receiver's row) since origin
no longer identifies the sender.

The new session-less POST /v1/sync/durability exchange exposes a node's
recorded destination durability vectors for a volume, so a peer can
pull and cache cross-node durability evidence for offline decisions.
The receiver no longer attributes received content to the immediate
sender: /close records each entry's declared (origin node name, origin
run) on the contents row, resolving the name to a local nodes row
(get-or-create — a forwarded origin may name a node this host never
peered with). Entries without the pair (pre-origin-exchange initiators)
fall back to the initiator at its declared sync run, which is the same
initiator-run-space coordinate.

Since origin now answers "where did the bytes first enter the system"
rather than "who sent them", the supersede-vs-conflict classifier
switches to delivery evidence: the receiver row's first_seen run and
its peer linkage + correlated initiator id. A forwarded origin still
supersedes through its forwarder; receiver-local writes and other
peers' deliveries still conflict.

The conflict pre-stage's drift relabel now clears the prior content's
origin: drifted bytes are a fresh local introduction, and inheriting
the old coordinate would make the durability vector vouch for content
no destination holds.

POST /v1/sync/durability (session-less, read-only) serves the node's
recorded destination vectors for a volume with origin nodes resolved
to names, backing the peer durability pull.
A node first met by name only (forwarded origin, durability pull
before any sync) gets the peer://<name> placeholder endpoint; the
first real handshake presenting an actual URL now upgrades the row in
place. Genuine real-endpoint collisions stay refused.
The kopia handler drives the kopia binary as an opaque child
process, mirroring the rclone wrapper's philosophy: squirrel owns
the argv, every invocation runs against a squirrel-managed,
destination-scoped config file next to rclone.conf, and the
repository password reaches the child only via KOPIA_PASSWORD in
its environment — never argv, logs, or error strings.

A push connects the repository at the destination root (falling
back to create on first use; connect runs every time so a moved
root is re-pointed instead of silently snapshotting into the old
repository), snapshots the volume path, and runs kopia's own
`snapshot verify` scoped to the new manifest. The typed
VerifyResult carries the manifest id plus the file/byte counts
from the manifest's root summary, and the runs row matches other
sync targets: kind='sync', destination=name, shallow=false since
kopia verifies its own hashes.

Restore refuses kopia destinations (the repository speaks kopia's
CLI), dry-run is refused (kopia has no equivalent), kopia pairs
are exempt from the rclone blake3 version preflight, and the index
snapshot ride-along stays out of the repository (local tier only,
like peer syncs).

Tests cover argv/env construction and status mapping through a
PATH-shim fake kopia, and a full connect→create→snapshot→verify
cycle against the real binary that skips when kopia is not
installed (verified locally against kopia 0.23).
ToolsFor resolves the kopia wrapper exactly when the matched pairs
include a kopia destination, in the CLI sync flow, per kick in the
agent's scheduler runner, and in the desktop's sync trigger — a
host that never targets a kopia destination runs without the
binary installed. The per-pair sync output renders the snapshot's
own numbers (files, bytes, manifest id, verified) since kopia
pushes have no rclone counters.
Covers config shape, the connect/create → snapshot → verify flow,
the password-via-environment and per-destination config-file
handling, and how kopia destinations differ from rclone ones
(self-verified, no shallow/dry-run, no crypt block, restore via
the kopia CLI). Also notes in the hooks section that verification
results come only from built-in destination types.
A dry run transfers and checks nothing, so stamping the derived
verification result would have reported a default bucket dry-run
as verified. Both flows now stamp only real runs, matching the
field's documented contract, with the dry-run path pinned by a
test.
Every /plan entry now carries its content's origin coordinate. Content
with a recorded origin forwards it verbatim — local node id resolved to
the node NAME (the cross-node identity), origin run untranslated.
Locally-introduced content is materialised as (self name, the content's
introduction run in the volume — its earliest first_seen_run_id, so a
later duplicate path doesn't shift the coordinate).

At a verified successful close (never dry-run) the initiator records
the durability consequence: AdvanceDestinationVector moves the peer's
vector over the volume's present set (peers are destinations in the
flat target namespace), then the new PullDurability fetches the peer's
own destination components and merges them monotonically into the
local destination_run_ids — metadata-only, refused rewinds surfaced as
warnings with an allow-rewind override reserved for the standalone
command. A failed vector advance fails the run; the next sync re-plans
everything already-correct and re-advances cheaply.

Existing tests that asserted immediate-sender attribution now assert
verbatim origins, and rows seeded as receiver-local writes are seeded
with genuinely local first-seen runs to match the delivery-based
classifier. The new 3-node chain test pins the acceptance scenario:
content introduced on alpha and forwarded bravo→charlie lands on
charlie with alpha's origin (and a nodes row for alpha, a peer charlie
never spoke to), while the forwarding hop still classifies cleanly.
Standalone entry point for the durability metadata pull that also runs
automatically after a successful node sync: fetch the peer's
destination vectors for a volume and merge them into the local index.
Refused rewinds are printed and fail the command with a pointer at
--allow-rewind, the explicit recovery override. The sync report now
also prints how many peer components the automatic pull applied.
An id-less manifest would have scoped `snapshot verify` to an
empty argument; surfacing the malformed manifest is the honest
failure.
…ding

- Strip any parent-shell KOPIA_PASSWORD before appending the
  configured one, so the child sees exactly one value (pinned by a
  stale-parent-export case in the happy-path test).
- Stamp Verification.Method at the start of a kopia push so output
  renderers use the kopia format even when the push fails before a
  snapshot exists.
- README: the runs row is never shallow, but verified is kopia's
  call (clean snapshot + passing verify), so say that instead of
  'always fully verified'.
The self component now aggregates one coordinate per present content —
its introduction run, the same MIN-first-seen coordinate the peer-sync
sender materialises on the wire — instead of each observation's
first_seen_run_id. A duplicate path observed later no longer advances
the component past the coordinates actually in circulation, keeping
the vector consistent with the origin space the gate compares against
(Copilot review on PR 99). Also stop ignoring a GetSelfNode error in
the transfer test.
mbertschler and others added 21 commits June 11, 2026 05:44
The indexer captured size and mtime at directory-walk time, then hashed
later from a fresh open with no re-stat. A file appended-to between the
walk and the hash bound the new BLAKE3 digest to the stale walk size in
an immutable contents row; every later observation of those true bytes
then failed the size cross-check in lookupContentTx and aborted the
whole ApplyIndexBatch repeatedly.

hashFile now Stats the open handle after hashing and returns the size
and mtime alongside the digest, so the row's metadata describes the same
inode state as the bytes that produced the hash. Re-opening by path
would reintroduce the race, so the live handle is used.

Refs #107
uploadOneObject stat-guarded the source on size+mtime before rclone
read it (a stat->copy race) and confirmed only size after upload, so a
size+mtime-preserving in-place edit could upload bytes that did not
match the recorded hash; the recorded remote_objects row then suppressed
any future re-upload of the honest content forever.

The pre-transfer guard now re-hashes the source with BLAKE3 immediately
before copyto and refuses (errContentDrift) when the digest no longer
matches the indexed hash. A refusal is surfaced as a warning and fails
the run without recording an object or writing the manifest segment, so
the watermark holds and the next run re-offers the object — the honest
bytes land once restored, and the drifted bytes never bind to the hash.

Residual: rclone reads the file in a child process after the re-hash, so
an edit in that fork/exec window could still upload drifted bytes; the
window is one rclone invocation rather than the whole walk-to-push span,
and the scan-back fingerprint pass re-reads the landed object before it
counts as content-verified.

Refs #107
Address Copilot review on #123:

- Scope-check before full validation. An out-of-scope destination is
  dropped before validateComponent/validateFreshness runs, so a junk
  entry for an unconfigured destination with a malformed origin can no
  longer abort the whole pull — restoring the intended fail-safe.
- Reconcile the counts. Fetched now counts components and freshness
  together and a merged freshness coordinate counts as applied, so
  every fetched entry lands in exactly one of applied/rewind/dropped
  and Dropped can never exceed Fetched.
- Bound the drop detail. Dropped stays exact, but the sampled Drops
  slice is capped (maxDurabilityDropSamples) and the sync warning is a
  single summary line, so a peer flooding out-of-scope destinations
  can't blow up the report or the output that renders it. The CLI
  printer prints the sample then a '… N more' tail.

Tests: freshness-drop path, the sample cap under a 50-destination flood,
and the count reconciliation.
config: add optional s3 storage_class and sftp host-key validation params
sync: scope durability pull to the volume's accepted destinations
index, sync: stop binding wrong bytes to a content hash (#107)
The v13->v14 reshape dropped files_blake3_immutable without installing
an equivalent guard on the new contents table. v21 re-asserts the
append-only contract the AGENTS.md guarantee implies: BEFORE UPDATE and
BEFORE DELETE triggers on contents that RAISE(ABORT). Defense in depth --
there is no UPDATE/DELETE path on contents today.

Regenerated store/schema.sql via -update-schema; golden test green.
Extended the minimal v18 fixture with the contents table a real v18 DB
carries, so the chain to v21 can attach the triggers.

Addresses #113.
Adds a populated v18 fixture (contents, files, remote_objects,
destination_run_ids) and drives it through the v19-v21 chain, asserting
the offload-substrate rows survive intact (verify_method NULL-backfilled,
remote_objects fingerprint preserved). Asserts the new v21 contents
triggers abort an in-place UPDATE and a DELETE while the row is unchanged.

Addresses #113 (#12c migration-shape coverage).
Offload defers to every run kind, but index and sync did not block on an
in-flight offload, so a sync could enumerate (or an index could
observe-and-flip) a tree mid-unlink. Add 'offload' to both begin-gate
blocking sets to make the exclusion symmetric. Tests both directions:
offload blocks a new sync and a new index/audit, and admits them again
once it finishes.

Addresses #114 (run-gate asymmetry).
hook_runs was the only runs-like table without FinishRun's terminal-state
guard: a double finish silently overwrote the first terminal record. Read
the status inside the finishing transaction and refuse with
ErrAlreadyFinished when it is already terminal, leaving the row untouched.
Test a double-finish is refused and the recorded outcome survives.

Addresses #114 (FinishHookRun terminal guard).
The shared requireIndexedVolume gate looked the volume up by name only. A
stale volumes.path would let a handler enumerate the config path's tree
while the durability advance covered the DB volume's rows — a wholesale
false durability claim. Refuse on mismatch, the same cross-check offload
and restore already make. Covers every push handler (bucket, kopia,
content-addressed, peer) since they share the gate. Test the mismatch is
refused before rclone is invoked.

Addresses #114 (config-path vs DB-volume-path agreement).
ensureRepository created a fresh repository on any connect failure. On a
transient outage or a mistyped path that mints an empty repository while
the destination's monotonic durability vector keeps claiming coverage the
new repository cannot honour — and there is no CLI to rewind the vector.
Require opt-in (the existing --init flag, which already gates the local
marker bootstrap): without it, a connect failure is an error, not a silent
create. Test connect-fail without --init refuses and never creates.

Addresses #114 (kopia repo re-create guard).
rcloneVerification set verified purely from flags + exit-0. When a backend
shares no hash with the source, rclone emits a NOTICE and silently falls
back to a size comparison; parseJSONLog dropped NOTICE-level events, so
the run was still recorded as blake3-verified. Detect the stable
'no hashes in common' phrase at any log level, flag it on RunResult, and
downgrade the verification to size+mtime (unverified) so the durability
vector does not advance on a copy rclone never content-checked. The guard
only ever downgrades, so a false match cannot wrongly mark a run verified.

Addresses #114 (--checksum blake3 silent degrade).
Schema & robustness hardening: contents immutability triggers (v21) + #114 cluster
rclone's sftp backend only autodetects md5sum/sha1sum, never a BLAKE3
command, so every non-shallow sync — which runs `rclone copy --checksum
--hash blake3` — aborted with "hash type not supported", even when the
remote has b3sum installed. Render `blake3sum_command = b3sum` in every
sftp section so rclone can compute server-side BLAKE3.
config: emit blake3sum_command for sftp so `--hash blake3` syncs work
…loods

Two cheap defence-in-depth guards on the peer durability pull. Neither is
a trust control — the peer is trusted to assert its own durability — they
turn a peer bug or version skew into a loud refusal instead of a silent
local effect.

- Add store.KnownVerifyMethod and refuse a non-empty verify_method the
  build does not recognise in validateComponent. Previously an unknown
  method was stored and then silently treated as unverified by the
  offload gate; now it is rejected at receipt. Empty (legitimately
  "unverified") still passes.
- Cap distinct origins one pull will resolve (maxOriginNodesPerPull) so a
  runaway peer cannot grow the local nodes table without bound via
  GetOrCreateOriginNode. A real volume references a handful of origins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KE9A1VTdw7k1GQq87fQyfx
Add an offload-v1 section to SAFETY-AUDIT.md framed for the single-operator
deployment (laptop + NAS, all owned, adversary is entropy not a hostile
peer):

- D1 documents the relayed-evidence trust boundary in the durability pull
  (direct self-verified vs peer-asserted components), states the accepted
  decision to trust an in-domain NAS, and records the two defence-in-depth
  guards added alongside.
- D2 documents that the content-addressed offsite push proves
  presence+size and decrypt-input correctness but not decrypt-output
  correctness, and sketches an opt-in, NAS-local read-back-decrypt-rehash
  verification as the proportionate close.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KE9A1VTdw7k1GQq87fQyfx
…ening

durability: document the relayed-evidence trust boundary + defence-in-depth guards
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants