Skip to content

codex: sync origin/main -> tinyland/main (repo-roam + per-device crypto; deny.toml + Taskfile union, 2026-06-08)#68

Merged
Jess Sullivan (Jesssullivan) merged 36 commits into
mainfrom
codex/sync-origin-main-20260608
Jun 9, 2026
Merged

codex: sync origin/main -> tinyland/main (repo-roam + per-device crypto; deny.toml + Taskfile union, 2026-06-08)#68
Jess Sullivan (Jesssullivan) merged 36 commits into
mainfrom
codex/sync-origin-main-20260608

Conversation

@Jesssullivan

Copy link
Copy Markdown

Syncs Jesssullivan/tummycrypt origin/main (c8a9714) onto tinyland-inc/main (cf886d9), carrying this session's repo-roam + per-device-crypto work that the lab flake builds from.

What comes over (23 commits origin had, tinyland lacked)

Conflict resolution (union — no coverage/feature dropped)

  • deny.toml — identical advisory ID set on both sides (RUSTSEC-2025-0141, -2026-0097, -2026-0173); kept origin's comment prose. No advisory coverage changed.
  • Taskfile.yaml (lazy:check) — UNION: keeps origin's git-roam-daily-driver-harness + dev-env-fingerprint tooling and tinyland's test-bazel-macos-package-contract.sh. Both script files are present in the merged tree; task --list-all parses.

Preserves all 41 tinyland-only Darwin/Bazel release-packaging commits (non-.git disjoint paths, no conflict). Non-destructive merge, no force-push.

This sync is the gate for bumping the lab flake.lock tummycrypt input (currently stale at e53bc72) to deploy the repo-roam fixes to neo+honey.

Operator-facing expand->contract migration sequence for moving the fleet
from shared-master-key wrapping to per-device age/X25519 FileKey wraps.
Doc-only acceptance artifact for the TIN-1417 design recon; lands no code
and flips no fleet config.

Captures the verified 7-step sequence (B1 FileProvider parity -> B3 restore
dual-write + roll-call gate -> canary both directions -> sign devices.json
-> tcfs key rotate -> keychain hardening -> strict flag flip LAST),
per-step rollback, the honest revocation claim boundary, the ratified
seven questions, and residual risks. Grounded in and cites
docs/ops/per-device-crypto-identity-design-2026-05-18.md.
…mode

Replace the shipped-but-unused `crypto.per_device_wrapping: bool` with a
tri-state `crypto.wrap_mode { Master, Dual, PerDevice }` (default Master),
forming an EXPAND/CONTRACT migration ladder for per-device file-key wrapping.

- config.rs: define WrapMode (serde snake_case master|dual|per_device, default
  Master via #[default]); hand-written CryptoConfig Deserialize keeps the legacy
  `per_device_wrapping` key as an alias (true -> Dual, false/absent -> Master);
  an explicit `wrap_mode` wins. Only `wrap_mode` is serialized going forward.
- engine.rs write path: branch on EncryptionContext.wrap_mode — Master emits the
  master wrap only (byte-identical to today, v2); Dual emits BOTH master +
  per-device wraps (v2, back-compatible); PerDevice emits per-device wraps ONLY,
  drops the master wrap, and bumps the manifest to v3. Dual/PerDevice with no
  recipients fail CLOSED.
- engine.rs read switch: version gate accepts v1/v2 and (crypto) v3; a binary
  without per-device support rejects v3 fail-closed; v3 with no wrapped_file_keys
  is rejected; unknown future versions rejected.
- Roll-call gate: DeviceRegistry::roll_call() + RollCall::all_capable(); the
  daemon/CLI/FP build_encryption_context REFUSE PerDevice (dropping the master
  wrap) until every active, non-revoked device has a real age recipient,
  otherwise downgrade to Dual and warn loudly.
- Rewire all per_device_wrapping readers to wrap_mode: tcfsd grpc.rs, tcfs-cli
  build_encryption_context, FP device_ctx/grpc_backend, and the FP config emitter
  (emits canonical wrap_mode; FP reader consumes wrap_mode with the legacy alias).

Default remains Master, so existing fleets are unaffected until the migration is
explicitly enabled. Tests cover each mode's manifest shape + version, the legacy
alias mapping, v3 fail-closed reads, the roll-call downgrade, and the
byte-identical Master default.
…evice)

Update the held migration plan to the ratified tri-state model, replacing
the prior two-bool per_device_wrapping (expand) + per_device_wrap_strict
(contract) framing.

- New "control surface" section: crypto.wrap_mode = master | dual |
  per_device (default master), with a table mapping each mode to the
  manifest shape, manifest version, and migration phase.
- Expand = dual (v2, both wraps); contract = per_device (v3,
  per-device-only, master wrap dropped). The v3-on-contract fail-closed
  rule is stated explicitly; dual stays v2 (back-compatible).
- New "roll-call code gate" section: the daemon/CLI/FileProvider refuse to
  contract to per_device until every active device is per-device-capable,
  otherwise downgrade to dual and warn (never silently drop the master
  wrap). Grounded in DeviceRegistry::per_device_roll_call_ready and the
  EncryptionContext.with_wrap_mode plumbing.
- Migration steps and per-step rollback re-expressed against wrap_mode;
  Step 7 is the per_device contract flip after a green roll-call.
- Back-compat documented: legacy per_device_wrapping true -> dual.
- Keeps the honest claim boundary, the ratified seven questions, and the
  residual risks; adds a residual-risk note on the v3 manifest namespace
  shared with symlink manifests (disambiguated by kind: symlink).
… a per-device identity

The engine read switch took the per-device branch whenever wrapped_file_keys
was non-empty and errored ('no age identity') if the context had no device
identity, never falling back to a present master wrap. A Dual manifest (v2,
carrying BOTH encrypted_file_key and wrapped_file_keys) read by a Master-mode /
no-identity context therefore failed closed instead of decrypting via the master
wrap, nullifying Dual's rollback/recovery rationale and breaking the live
daemon/FileProvider read path whenever a Master device reads peer-written Dual
content.

Attempt the per-device unwrap first; on any failure or unavailability (no ctx,
no identity, or no stanza addressing this device) fall back to the master wrap
ONLY when encrypted_file_key is actually present. A PerDevice/v3 manifest
(encrypted_file_key = None) stays strictly fail-closed and surfaces the
per-device error. Master writes remain byte-identical.

Add a positive test (Dual write, master-only read -> byte-exact plaintext) and
keep the v3 negative (master-only read of a per-device manifest stays
fail-closed).
…de-enum

DRAFT: TIN-1417 tri-state crypto.wrap_mode migration (agent-drafted, needs review)
…-crypto-migration-v2

DRAFT: TIN-1417 migration doc — tri-state crypto.wrap_mode (supersedes Jesssullivan#490) (agent-drafted, needs review)
…ig (sandbox read path)

The macOS FileProvider .appex cannot fs-read ~/.config/tcfs under the sandbox,
so the per-device wrap read path (device registry + device-<id>.age) was
unreachable in-sandbox. This mirrors the existing master_key_base64 Keychain
inlining for the per-device material.

Rust read-side (TESTABLE, full gate green):
- crates/tcfs-file-provider/src/device_ctx.rs: build_encryption_context now
  resolves recipients and this device's secret INLINED-FIRST, preferring
  config values (device_recipients / device_recipients_all_capable /
  device_secret) over the on-disk registry/secret read. The fs read remains the
  fallback for the non-sandboxed daemon/CLI and tests. Every inlined recipient
  is re-validated with is_real_age_public_key; the PerDevice roll-call gate is
  driven by the host-supplied all_capable signal on the inlined path. wrap_mode
  default (master) stays byte-identical.
- Added 5 tests: inlined-preferred-without-fs, all_capable=false downgrade to
  Dual, fs fallback when not inlined, mixed inlined-recipients + fs-secret, and
  inlined-secret-without-recipients fail-safe to master-only.

Swift host (DRAFT, UNVERIFIED — cannot build/test in sandbox; needs real-device
Xcode build + FileProvider QA):
- swift/fileprovider/Sources/HostApp/HostApp.swift: configForKeychain now also
  inlines the active per-device recipients and this device's age secret into the
  Keychain config copy, INERT when wrap_mode=master. Mirrors the master-key
  inlining pattern exactly.

Gate (Rust only): cargo fmt --check CLEAN; clippy (grpc + default) no NEW
warnings (pre-existing ensure_master_decryptable dead-code warning only); cargo
test -p tcfs-file-provider --features grpc all pass.
The device registry doubles as the per-device age recipient set, but it was
plain serde_json with no signature: anyone with direct object-store write to
meta_prefix/tcfs-meta/devices.json could inject a hostile recipient, un-revoke a
device, or drop one, defeating per-device revocation (SEV-1). signing_key_hash
is only a self-fingerprint, not a signature; the module doc-comment falsely
claimed the registry was "signed by the master identity".

Fix:
- DeviceIdentity gains revoked_at, enrolled_by, signing_pubkey (all optional,
  back-compat deser; revoke() now stamps revoked_at).
- DeviceRegistry carries an Ed25519 signature envelope (registry_signature,
  signer_pubkey, sig_alg) over a canonical, order-independent serialization of
  the device list. The signing key is HKDF-SHA256-derived from the master key
  (domain tcfs-device-registry-signing-v1) so any master-holder can sign/verify
  with no new key distribution.
- sign()/verify_signature() + load_verified()/save_signed() and remote variants.
  verify_signature binds to the master-derived signer first (a sig from any
  other key is rejected), then verifies; a tampered list is a hard error; an
  unsigned legacy registry is RegistryTrust::UnsignedLegacy (bounded migration
  window, loud warn).
- Recipient-set builders (tcfsd grpc.rs, tcfs-cli main.rs, file-provider
  device_ctx.rs build_encryption_context) now load_verified and REFUSE to build
  a per-device recipient set from an unsigned/tampered registry — they fall back
  to the shared master wrap (fail-closed), never wrapping to an unverified
  recipient.
- Writers sign: tcfs init, device enroll, device revoke, device-id backfill,
  remote sync; the daemon re-signs a first-run/unsigned registry once the master
  key is available.
- Fixed the false doc-comment.

wrap_mode stays master by default; the Master path returns before any verify, so
default behavior is byte-identical and an unsigned registry serializes without
the new fields.

Tests (tcfs-secrets + readers): tampered devices.json (inject recipient /
un-revoke / flip field) fails verification on load; a registry signed by an
unrelated key is rejected; forged signer pubkey rejected; recipient-set builders
refuse an unverified registry; sign->serialize->load->verify round-trip; legacy
unsigned registry loads with a warn; back-compat serialization guard.
The enroll --sync-remote path discarded the RegistryTrust returned by
load_remote_verified, so an UnsignedLegacy (signature-stripped) remote was
merged unconditionally and then re-signed locally with the real master key.
An attacker with object-store write access could strip the signature and
inject a hostile recipient, laundering it into a validly-signed registry.

Fixes:
- Bind the remote trust on the merge path via enforce_remote_merge_trust:
  refuse to merge an UnsignedLegacy remote unless the operator explicitly
  passes --accept-unsigned-remote (loud warning + re-sign). A signature-
  present-but-invalid remote stays hard-rejected by load_remote_verified.
- Correct the overstated comment at the load_remote_verified call site.
- Harden verify_signature to verify_strict() (rejects malleable/small-order
  signatures); drop now-unused Verifier import.
- Document the UnsignedLegacy migration window with a dated hard-reject
  criterion (TIN-1417 B5, 2026-09-01) on both RegistryTrust::UnsignedLegacy
  and enforce_remote_merge_trust.

Tests: unsigned_remote_with_injected_recipient_is_refused_not_laundered
reproduces the full attack end-to-end against an in-memory object store and
proves the merge is refused (with a counterfactual showing the laundering
would otherwise succeed); plus accept_unsigned_remote_flag_allows_merge and
signed_remote_passes_merge_gate. Existing B4 tests stay green.
… in Rust, correct trust-scope docs, bech32-decode Swift validation

device_ctx.rs:
- Correct false doc claims that inlined recipients are 'already-VERIFIED'.
  The Swift-read registry is NOT signature-verified (that is B4, separate/
  unmerged). Comments now state recipients are re-validated for age-key
  WELL-FORMEDNESS only; registry AUTHENTICITY is out of scope (pending B4).
- Hardening (availability): do NOT trust the host-provided all_capable
  boolean. Swift isRealAgePublicKey was a prefix/length heuristic; an
  over-count could set all_capable=true and let Rust drop the master wrap
  prematurely -> device lockout. all_capable is now RE-DERIVED in Rust from
  its OWN re-validated recipient set (true iff every inlined entry parsed as
  a real age::x25519::Recipient).
- Tests: replace the host-boolean-trusting downgrade test with two
  re-derivation guards: host all_capable=true + a malformed recipient
  re-derives FALSE in Rust (-> Dual, no lockout); host all_capable=false is
  overridden to PerDevice when recipients are well-formed.

HostApp.swift (DRAFT, not buildable here):
- Harden isRealAgePublicKey toward a real bech32 decode (HRP=age, charset,
  BCH checksum, 32-byte X25519 payload) instead of prefix+length. Still
  needs a real-device Xcode build + FileProvider QA; does not claim Swift
  compiles.
…ed-registry

DRAFT: TIN-1417 B4 sign the device registry (Ed25519, master-derived) (agent-drafted, needs review)
…n-inlining

DRAFT: TIN-1417 inline per-device recipients + age secret into Keychain config (agent-drafted, needs review)
proc-macro-error2 is an unmaintained-advisory (not a vulnerability), pulled
as a build-time proc-macro dep of age via i18n-embed-fl (age 0.11.2 ->
i18n-embed-fl 0.9.4 -> proc-macro-error2). No runtime or security impact;
not removable without churning the age dependency. Ignored with justification,
consistent with the existing bincode/rand transitive-advisory ignores. Fixes
the persistent cargo-deny red on main.
…y-proc-macro-error2

fix(fileprovider): restore cargo-deny and grpc-only build gates
…jects

Track A roam enrollment: ~/.claude/projects is a disjoint 733MB tree outside
the single TCFS sync_root, so it is enrolled via a scheduled
'tcfs reconcile --path --prefix --execute' unit rather than a sync_root change.

Spec includes paste-ready nix for tummycrypt.nix (extraReconcileRoots option +
tcfsReconcileScript builder sourcing creds like daemonWrapper + Darwin launchd
agent and Linux systemd oneshot+timer), per-host enablement for macbook-neo and
honey on the shared agent/claude-projects prefix, the bounded-subset-first
rollout with TCFS_UPLOAD_ASSUME_FRESH_PREFIX guidance, per-cycle log/deny-set/
SHA256-after-decrypt verification, and explicit operator nix-switch steps.

Doc only; no code or wrap_mode change. Engine reuses the daemon's master wrap
and fail-closed deny-set; reconcile is not FileProvider-gated.
The legacy direct/uniffi FileProvider backends only perform master-key
unwrapping (no per-device age identity). Their fail-closed guard rejected
ANY manifest carrying wrapped_file_keys, which also locked out Dual (v2)
manifests that carry BOTH a master encrypted_file_key AND per-device
wraps.

Mirror the engine read switch (crates/tcfs-sync/src/engine.rs ~:2395):
reject only when wrapped_file_keys is non-empty AND encrypted_file_key is
None (PerDevice/v3). When the master wrap is present (Dual/v2), fall back
to it. PerDevice/v3 (encrypted_file_key=None) stays strictly fail-closed —
never materialize raw ciphertext. wrap_mode=master stays byte-identical.

- device_ctx.rs: ensure_master_decryptable now permits Dual, rejects
  PerDevice/v3 (used by direct.rs fetch + fetch_with_progress).
- uniffi_bridge.rs: same condition applied inline to hydrate_file and
  hydrate_file_with_progress (preserving the Decryption error variant).
- Tests: Dual reads via master wrap on direct + uniffi backends;
  PerDevice/v3 fails closed on both; master-only unchanged.
…-read-fallback

DRAFT: TIN-1898 FP direct/uniffi backends read Dual/v2 via master-wrap fallback (agent-drafted, needs review)
…e-projects-enrollment

DRAFT docs(ops): roam-enroll ~/.claude/projects via scheduled reconcile unit (agent-drafted, needs review)
Add a NEW scoped command `tcfs key rotate <prefix> [--rotate-keys] [--resume]`
(separate from the master `rotate-key`). For every manifest under <prefix> it
decrypts the current FileKey (master or per-device per the wrap shape), generates
a fresh random FileKey, re-encrypts each chunk under it (new BLAKE3 content
addresses), re-wraps to the CURRENT (post-revocation) recipient set resolved from
the verified device registry honoring wrap_mode, publishes the new manifest, and
GCs orphaned old chunks only after publish via the existing reference-safe
cleanup_orphaned_chunks sweep. Resumable via .rotate-state.json.

Make recipient-set removal the default for `tcfs device revoke` (cheap/immediate)
with a loud forward-secrecy warning, and gate FileKey rotation behind
`key rotate --rotate-keys` with a projected-bytes confirmation prompt.

Rename the misleading skipped_plaintext_manifests counter: split into
skipped_keyless_manifests (genuinely plaintext) and skipped_per_device_manifests
(per-device-only, which the master rotate cannot re-wrap) so the master rotate no
longer silently skips per-device manifests as if plaintext.
Reviewer must-fixes for PR Jesssullivan#504 (scoped per-device FileKey rotation):

1. cmd_key_rotate previously printed the forward-secrecy reassurance
   UNCONDITIONALLY. Under the DEFAULT WrapMode::Master (and Dual) the
   re-keyed FileKey is re-wrapped to the UNCHANGED shared master key, so a
   revoked master-key holder STILL decrypts the re-keyed content -- the
   message was false and dangerous. Gate the reassurance on
   wrap_mode == PerDevice && !device_recipients.is_empty() via the new
   rotation_grants_forward_secrecy / forward_secrecy_summary_lines helpers;
   Master/Dual now print a LOUD warning that no per-device forward secrecy
   was gained. Adds 4 tests covering the Master, Dual, PerDevice, and
   empty-recipient PerDevice messaging paths.

2. Orphan-chunk GC defaulted to grace=0 (immediate), which could 404 a
   concurrent reader holding an old chunk address in a multi-writer fleet.
   Default to the configured orphan_chunk_cleanup_grace_secs; add a
   --gc-immediate flag for grace=0. GC stays reference-safe (only chunks
   unreferenced by ANY live manifest are eligible). Surface a
   deferred-within-grace count so deferred orphans are not mistaken for lost.

3. Dry-run wording no longer claims re-chunking: rotation re-encrypts per the
   existing index (file_hash/file_id stay valid).
DRAFT: TIN-1899 scoped per-device key rotation for forward secrecy (agent-drafted, needs review)
Adds the one missing precision layer the large-workdir research identified for
Gate G5 / TIN-1620 (one expendable live repo): a git-aware dev-env fingerprint
that asserts a roamed repo is byte-and-semantically identical across hosts, with
no mid-reconcile .git corruption. This is a tighter assertion layered ON TOP of
the existing QA-matrix rows (T2/T3 exact bytes, T8/T9 + M3/M6 peer-edit
rehydrate, T10/T11 + M5/M5-R conflict/keep-both, T12 symlink, T13 modes), not a
new matrix and not a new sync engine.

scripts/repo-roam-fingerprint.sh: capture/compare/seed-canary/self-test. Capture
records git status --porcelain=v2 --branch, HEAD+branch+symbolic-ref, refs,
staged vs unstaged diff hashes, index blob shas, untracked, stash, reflog tip,
git fsck --full, and a sorted working-file sha256 manifest honoring the
fail-closed reconcile deny-set (.env*/secret/live-WAL recorded DENIED, never
hashed). compare exits nonzero on ANY difference and requires fsck clean on both
sides. seed-canary builds a throwaway repo (feature branch + staged + unstaged +
untracked + stash + exec script + symlink) and refuses $HOME / ~/git / fs-root.

The tool plugs into the existing canary/evidence scaffold (git-repo-canary.sh ->
home-canary-linux-xr-shadow.sh for shadow/push, git-repo-restore-proof.sh for
rollback, the neo-honey / TIN-1620 flip-flop harnesses for cross-host lifecycle)
and emits one new dev-env-fingerprint/ subtree plus one dev-env-zero-diff gate
line; it does not duplicate any of them.

docs/ops/repo-roam-test-plan-2026-06-08.md: the canary runbook, mapping each
step (R0-R5) to the EXISTING T/M rows + the TIN-1620 G5 acceptance, marking
LIVE-fleet steps as out of scope for this PR. Documents the Facet-4 enrollment
fact (.git-as-files is config-scoped via a per-repo -c config with
sync_git_dirs=true + git_sync_mode="raw"; ~/git enrollment works with NO Rust
change; the global flip is forbidden for blast radius), the Facet-5 zero-diff
caveats (mtime/index trap needing a git update-index --refresh mitigation;
symlink drop in the reconcile collect path), and the Facet-6 raw-mode .git
corruption gate (G5-git-5 expected-fail until conflict resolution is .git-aware).
Flags the .git-as-files vs git-bundle tension and the unlisted stash assertion as
operator-review items.

Taskfile: lazy:dev-env-fingerprint + lazy:test-dev-env-fingerprint, mirroring the
existing inventory/canary task surface.

shellcheck-clean; self-test (disposable /tmp seed -> capture -> capture ->
compare, plus drift-detection negative control) and the regression suite both
pass. No live fleet touched.
Three must-fixes for the dev-env zero-diff fingerprint (PR Jesssullivan#507):

1. capture is now genuinely read-only. Drop the `git write-tree` call from
   head.env — it wrote tree objects into <repo>/.git/objects and touched the
   index, breaking the read-only contract that the live R0 step relies on when
   pointing capture at real expendable repos. The staged/index identity is
   already captured by `git ls-files -s` (index-blobs.txt) + the
   `git diff --cached` hash (diff-cached.sha256), so write-tree was redundant.
   Header + runbook read-only claims corrected to be TRUE.

2. Tighten the fsck corruption grep to genuine signals only:
   ^error: / ^fatal: / invalid sha1 pointer / broken link. Drop the broad
   `missing` / `dangling.*commit` matches that false-positive on healthy repos
   with gc'd / expired reflogs (a lone `dangling commit <sha>` notice was
   wrongly flipping fsck=dirty).

3. Make the [PR] vs [LIVE] boundary unmissable in the runbook: a green
   self-test only proves the assertion engine is internally consistent on one
   host — it is NOT proof of flip-flop zero-diff in either direction or of live
   .git corruption catching. Those are delegated to the [LIVE] R2/R3/R5 steps
   and the Facet-6 harness (PR Jesssullivan#506).
…0 T13-Z)

Two blockers for zero-diff repo roam, where a tree must roam neo<->honey
with `git status` staying clean.

BLOCKER A — preserve source mtime on restore. A fresh restore otherwise
stamps "now" onto every file, smudging .git/index's stat cache and making
`git status` report spurious-dirty. Add an OPTIONAL `mtime: Option<(i64,u32)>`
(unix secs + subsec nanos) to SyncManifest, captured at upload from the same
std::fs::Metadata that already yields file_mode, and re-applied via
utimensat (cfg(unix)) after the atomic rename and BEFORE make_sync_state_full
re-stats, on both the empty-file and chunked-file restore paths.

Back-compat: the field is #[serde(default, skip_serializing_if = ...)], so old
manifests deserialize to None (restore unchanged) and an mtime:None manifest
serializes byte-identically to a pre-field one. Manifests are content-addressed
by the file's BLAKE3 hash (manifests/{file_hash}); the manifest's serialized
bytes are never hashed for addressing/dedup, so the field cannot cause the
fleet to re-upload or re-address existing content.

BLOCKER B — opt tracked symlinks in. collect_local_set now sets
preserve_symlinks:true (follow_symlinks stays false) and folds the collected
symlinks into the reconcile map, and the push path dispatches symlinks to the
first-class symlink-manifest uploader instead of dereferencing them. The
restore side + deny-set guard already handle symlinks.

Tests: mtime round-trips (empty + chunked); old manifest w/o mtime restores
with today's behavior and serializes without the key; symlink round-trips
through reconcile push->restore with its target intact.
…rprint

TIN-1620: dev-env zero-diff fingerprint for the repo-roam ladder
…1620)

compare_both_exist hashed symlinks via hash_file, which dereferenced the
link and hashed the target's content, then tried to parse the stored
SymlinkManifest as a SyncManifest. That parse failed, so a tracked symlink
present on both local and remote was classified Push{NewLocal} every cycle
and never reached UpToDate — a symlink-bearing repo re-pushed forever.

Add a symlink-aware branch: detect the local symlink with symlink_metadata
(no follow), read its target, and compare on symlink identity using the
exact scheme the push path writes — engine::symlink_manifest_hash(target),
the same hash stored in the manifest hash, the remote index entry, and the
local sync-state blake3. The remote manifest is parsed as SymlinkManifest
(the type upload_symlink_with_device serialized) and fed through
compare_clocks, reusing the regular-file UpToDate/Push/Pull/Conflict logic:
identical targets short-circuit to UpToDate, divergent targets fall to the
vector-clock decision. Fails closed to a conservative re-push if the remote
manifest is not a parseable symlink manifest.

Regular-file path is unchanged. symlink_manifest_hash and
read_symlink_target_text are promoted to pub(crate) so reconcile reuses the
push path's identity rather than reinventing it.

Tests: reconcile_tracked_symlink_converges_up_to_date asserts a second
reconcile of a pushed symlink plans UpToDate (fails as Push{NewLocal}
before the fix); reconcile_changed_symlink_target_is_not_up_to_date asserts
a repointed symlink surfaces as Conflict, so the fix is non-vacuous.
…user-story-test-plan-20260608

docs: define git roam daily-driver acceptance
…-fingerprint-denyset-20260608

test: harden repo-roam fingerprint deny-set
…fidelity

DRAFT: reconcile restore fidelity (mtime preserve + symlink opt-in) for zero-diff roam — TIN-1620 T13-Z (agent-drafted, needs review)
…n-20260608

# Conflicts:
#	Taskfile.yaml
#	deny.toml
@Jesssullivan Jess Sullivan (Jesssullivan) merged commit 2ee697b into main Jun 9, 2026
12 of 13 checks passed
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.

1 participant