Skip to content

Desktop Existing-Project Adoption: Pre-Apply Security Review ​

Status: accepted review for the next bounded implementation slice. This document does not implement Apply.

Date: 2026-08-19.

Scope: Intentloom Desktop existing-project adoption after PRs #337, #339, #340, #342, and #343. Consumer: local existing repositories selected through Desktop. This review does not apply to Vii as a product.

Decision ​

A. READY FOR BOUNDED APPLY IMPLEMENTATION.

The canonical mutation machinery already exists in @intentloom/application (adoptProject → syncProject → synchronizeGeneratedFiles). Desktop Apply must reuse that path. It must not invent a second transaction engine, and it must not call applyProjectAdoption (a different, narrower governance-pack writer).

Crash-safe recovery is not a prerequisite for the first Apply PR. The first release may only claim handled-error rollback. Process crash during mutation is not crash-atomic today. That bound is explicit below and must remain honest in protocol, UI, and tests.

Exact next PR: implement intentloom.existing-project.adoption.apply.v1 as a mutating, authenticated, allowlisted RPC that authorizes an ExistingProjectAdoptionApproval receipt against a live prepared plan, then applies through adoptProject / synchronizeGeneratedFiles, then runs read-only doctorProject and diffProject.

Current flow (merged) ​

text
Select existing project
-> Inspect
-> Preview (adoptProject dry-run)
-> Resolve supported decisions
-> Validate
-> Prepare (security envelope)
-> Revalidate
-> Explicit local-interactive approval

Prepared ≠ approved ≠ applied. Approval currently returns approved: true, applied: false, changesApplied: 0. Tauri still denies intentloom.existing-project.adoption.apply.v1.

1. Current mutation primitives ​

There are three write-capable adoption-adjacent paths. Only one is the Desktop existing-project engine.

PrimitiveRoleUse for Desktop Apply?
adoptProject → syncProject → synchronizeGeneratedFilesCanonical generated-file adoption: creates/updates Intentloom-managed files, writes .aif/manifest.lock.json and .aif/source-map.json, post-write consistency, in-process rollbackYes. Required. Preview already uses adoptProject({ dryRun: true }).
executeApprovedApplyPlan / evaluateApprovedApplyPlanGeneric Approved Apply gate + wrapper around synchronizeGeneratedFilesPartial reuse. Keep the gate ideas (expiry, state digest, explicit grant). Do not replace the typed adoption approval receipt with the coarse atomic-commit-approval string.
applyProjectAdoptionDuty-watch/governance pack apply: create ops with hardcoded bodies, expectedCurrentHash pre-loop, .aif/migration-journal.json after successNo. Wrong artifact set. Not the generated-skill/metadata transaction.

Evidence:

  • Preview: packages/application/src/existing-project-adoption-plan.ts calls adoptProject({ dryRun: true }) and throws if proposal.applied.
  • Transaction: synchronizeGeneratedFiles in packages/application/src/index.ts (collision check, noncanonical path check, safeDestination per file, backup/create tracking, write, post-write validation, rollback).
  • adoptProject live write: when not dry-run and not blocked, calls syncProject({ dryRun: false }).
  • applyProjectAdoption: only writes create operations plus a journal file; map-existing is a no-op write.

Planned action inventory (Desktop existing-project Apply) ​

These are the action classes produced by adoptProject items, not applyProjectAdoption operations.

ActionPreconditionExpected-beforeWriteRollbackFailure evidenceIdempotency
Create generated file (adapter skill, missing .aif file)writeEligible, destination absent, not project-owned, no blocking conflictPath absent (or later overwritten only if already Intentloom-owned)fs.mkdir + fs.write via synchronizeGeneratedFiles after safeDestinationDelete created pathTransactionResult.failedStage, diagnosticsReplay after success should be already-applied if live bytes already match desired payload
Update generated / metadata fileExisting Intentloom-owned or recognized metadata; content differsLive bytes captured into in-memory backups immediately before writeSame write pathRestore backup bytesSameSame
Mapping metadata (project-owned / documentation mappings)Validated decisions; mappings passed into adoptProjectEncoded in .aif/config.yaml desired contentConfig write as part of desired generated setRestore previous config or delete if createdSameSame
Generated skill writeCatalog/adapter generation; path canonical; not secret-like scan targetCollision/noncanonical checks fail closed before any writeSameRestore/deleteSameSame
Manifest / source-map writeBuilt from desired generated files; included in the same transaction batchExisting lock/source-map bytes backed up if presentWritten after generated files in the same loopRestore/deletePost-write validation codesSame
Directory creationParent of a writeDirectory may already exist (mkdir recursive)fs.mkdirDirectories created for new files are not separately rolled back; file remove remainsIncomplete rollback if mkdir succeeded and later file write fails but remove failsHarmless leftover empty dirs possible

FileSystem.write is writeFile (not temp-file + rename). Per-file replacement is not POSIX-atomic. The transaction is logical: all planned files then rollback on thrown error.

2. Current rollback guarantees ​

FailureClassificationEvidence
Before first write (gate/revalidate fail, collision, noncanonical path, blocking diagnostics)Fully recoverable (zero writes)synchronizeGeneratedFiles returns failed with rollbackAttempted: false
Midway through multiple file writes (handled exception)Recoverable with evidenceIn-memory backups restored; created files removed; rollbackCompleted true if every restore/remove succeeds
Metadata write failureRecoverable with evidence if the error is thrown inside the tryManifest/source-map are in the same batch
Generated skill write failureSameSame
Permission / disk full / IO errorRecoverable with evidence if write throws; partially recoverable if rollback itself fails (transaction-rollback-incomplete, failed-incomplete)rollbackFailures
Unexpected local modification during the write loopNot CAS-protected; last writer wins for that path unless safeDestination throwsSee TOCTOU
Cancellation mid-writeNot currently covered by the transaction API (no deferred-cancel flag)Must be designed in Apply
Daemon / process crash mid-writeNot currently coveredNo durable journal. .aif/migration-journal.json is only written by applyProjectAdoption after success and is not this engine
Post-write consistency invalidRecoverable with evidenceValidation failure throws, then rollback

Do not claim process-crash atomicity. CLI adopt already ships this same in-process rollback model.

3. TOCTOU findings ​

Window: final revalidation → first filesystem write, and then write N → write N+1.

What current APIs do:

  1. revalidateExistingProjectAdoptionPreparedPlan recomputes digest, plan id, expiry, root, preview identity, fingerprint, decisions, and blocking diagnostics. It does not take a mutation lock.
  2. synchronizeGeneratedFiles then re-reads live bytes to decide create/update and to fill backups, then writes. That is not compare-and-swap against the approved fingerprint.
  3. applyProjectAdoption checks expectedCurrentHash for all ops first, then writes later. That is still two-phase TOCTOU. Desktop must not treat it as a solution.
  4. safeDestination runs immediately before each write: rejects symlink destinations and realpath escape from canonical root.

Project state can change between validation and mutation (editor, other CLI, second Desktop, git checkout).

Required fail-closed control for Apply (implement in the Apply PR, not a new engine):

  1. Acquire an in-process exclusive lock keyed by canonical project root.
  2. Under that lock, run the full 20-gate list including a fresh fingerprint.
  3. Only then call adoptProject / synchronizeGeneratedFiles.
  4. Keep safeDestination before every write (already present).
  5. If fingerprint or revalidation is not valid under the lock: zero writes.

This does not stop a non-Intentloom process from mutating the tree during the locked write loop. The remaining residual is accepted for the first release and must be tested as “external edit during apply is undefined relative to the approved snapshot; post-apply doctor/diff will surface drift.” A durable filesystem lease is out of scope unless later evidence shows in-process locking is insufficient.

4. Replay / idempotency ​

Current approval receipt is caller-held. Duplicate receipts are equivalent. There is no daemon approval database and no consumed-token store. Neutron inFlightSessions is unrelated.

Required Apply policy (deterministic, no new persistence):

SituationBehavior
Same approval, project still matches approved fingerprint, not yet appliedFirst caller under the root lock applies; second waits then sees live state
Same approval after successful ApplyDo not rewrite. If desired generated bytes and ownership metadata already match the plan payload, return already-applied with changesApplied: 0. If the tree drifted, fail closed (stale-fingerprint / stale-digest)
Same approval after successful handled rollbackFingerprint should again match the approved snapshot; Apply may proceed (retry)
Same approval after failed-incompleteFail closed. Return recovery evidence. Do not claim applied
Concurrent duplicate ApplySerialized by per-root lock. No double write of the same transaction

Approval is not single-use in a persistent sense. It is single-flight per root and logically consumed by a successful apply of that plan identity. Replay after success is idempotent already-applied, not a second mutation.

Do not write approval records into the consumer repository.

5. Concurrency ​

ScenarioRequired behavior
Two Apply calls, same canonical rootExclusive per-root lock in the daemon process. Second waits or fails with a typed in-progress error. Prefer wait-with-deadline then already-applied or fail closed
Two Apply calls, different rootsAllowed in parallel
Doctor / Diff / Preview / Inspect while Apply holds the lockDoctor/Diff/Preview are read-only. They may observe a torn tree if they run during writes. Require: while a root lock is held for Apply, other mutating methods for that root wait; read-only methods should either wait for the lock or return a typed mutation-in-progress diagnostic. Smallest safe rule: all project-scoped RPCs for that canonical root wait on the same lock for the Apply critical section, so Doctor/Diff after commit see a stable tree. Preview during Apply should not start a second adopt dry-run mid-write
Daemon restart during ApplyLock is lost. Crash residual applies (see §6)

Lock scope: per canonical project root (realpath of the approved root). Not daemon-global.

6. Crash recovery ​

There is no durable transaction journal for synchronizeGeneratedFiles. nodeFileSystem.write is not rename-atomic. Daemon restart during Apply can leave a subset of generated files plus partial metadata.

First Apply release may guarantee:

  • rollback on handled runtime errors when rollback itself succeeds;
  • failed-incomplete with path evidence when rollback fails.

First Apply release must not guarantee:

  • crash-safe atomic recovery;
  • automatic repair after SIGKILL.

After a crash, the next session must Inspect → Doctor → Diff. Partial metadata already maps to partial-metadata inspection readiness. The user re-previews; a stale fingerprint/plan fails closed.

Durable journaling is not a blocker for this first Desktop Apply slice because it would be a new subsystem the CLI adopt path also lacks. It is a documented residual risk, not a silent claim.

Existing controls:

  • Daemon canonicalProjectRoot: absolute, exists, directory, rejects root symlink, returns realpath.
  • Application assertCanonicalProjectRoot / fingerprint / approve / plan: reject symbolic-link roots.
  • inside() / fingerprint containedPath(): traversal (..) fails closed.
  • safeDestination: walk parents; symlink current path is security-error; realpath must stay under canonical root.
  • normalizeStoredPath / storedPathCollisionKey: reject absolute/Windows device names; collision key is lowercased (case-insensitive collision detection for generated destinations).
  • nodeFileSystem.list skips symlink entries when scanning.
  • Fingerprint skips symlink files (they are omitted from the hash).

Required immediately before each transaction mutation (already in synchronizeGeneratedFiles; Apply must not bypass it):

  1. Reconfirm canonical root is not a symlink and still equals approved root.
  2. safeDestination for the concrete write path.
  3. Fail closed on collision keys and noncanonical stored paths.

Gaps to keep fail-closed in Apply gates (before first write):

  • Affected path became a symlink after approval: fingerprint may have skipped it; safeDestination must still abort that write and roll back.
  • Parent directory became a symlink: safeDestination walk should catch it.
  • Generated destination escaping root: inside + safeDestination.
  • Case-insensitive collision between a generated path and an existing project-owned file: findDestinationCollisions covers generated set collisions; Apply tests must include a macOS-like case-fold fixture.

8. Post-apply Doctor / Diff / Ready ​

A successful filesystem transaction is not Ready.

Required order:

text
authorize + lock
-> adoptProject / synchronizeGeneratedFiles
-> if transaction failed: return failure (rollback status); do not call Ready
-> if transaction succeeded: doctorProject (read-only)
-> diffProject (read-only)
-> evaluate Ready

doctorProject and diffProject (plan) do not write. They must keep using the same FileSystem against the committed tree. Do not pass a mutating transaction option.

Do not roll back a committed successful transaction because Doctor or Diff is unhappy. That would destroy an already-consistent generated state due to unrelated warnings (documentation missing, instruction-root warnings, formatter noise). Post-apply verification failure is committed-but-needs-attention.

Ready (all required):

  • applicationStatus applied or already-applied;
  • transactionOutcome.status === "success" (or already-applied with matching live generated state);
  • Doctor: no severity === "error" findings (info installation-healthy may be present);
  • Diff: no unmanaged Intentloom generated drift (conflict / modified / security-error on Intentloom-managed paths). Project-owned mapped files remaining unchanged is success;
  • Inspection readiness ready (metadata present).

Not Ready: any Doctor error, unmanaged generated drift, failed-restored, failed-incomplete, denied authorization.

Warnings stay visible and must not be upgraded to Ready by transaction success alone.

9. Apply result model ​

Do not invent a Desktop-only result. Compose existing types into one protocol viewmodel (same pattern as approve/prepare):

Reuse:

  • AdoptionProposal.applicationStatus and AdoptionTransactionOutcome;
  • TransactionResult path lists (createdFiles, updatedFiles, unchangedFiles);
  • Doctor findings/errors;
  • Diff Plan.changes (content omitted or redacted; paths + kinds only);
  • Approval identity fields already on ExistingProjectAdoptionApproval;
  • Prepared plan preparedPlanId / planDigest / root.

Conceptual result (protocol names to be added in the Apply PR):

  • status: applied | already-applied | denied | rolled-back | failed-incomplete | applied-needs-attention
  • canonicalRoot, preparedPlanId, planDigest, approvalId
  • transactionId: omit unless a real engine id exists. Do not fake one. AdoptionTransactionOutcome has no transaction id today.
  • appliedPaths / unchangedPaths from TransactionResult
  • rollbackAttempted / rollbackCompleted / rollbackFailures
  • changesApplied: count of created+updated files; 0 for already-applied and denied
  • doctor / diff viewmodels (existing daemon doctor/diff contracts where possible)
  • diagnostics (safe codes only; adoptProject already sanitizes)
  • ready: boolean
  • applied: true only when filesystem mutation committed or already-applied equivalent; never when denied or rolled back

Evidence returned to the caller: identities, path names, rollback failure paths, doctor/diff summaries. Do not return file contents, backup bytes, or secret-like paths. executeApprovedApplyPlan currently stores previousContent in rollback evidence — Desktop Apply must not copy that shape into the protocol response.

Do not write an audit file into the consumer repo unless a later ADR requires it. Current architecture does not.

10. Protocol / daemon / Desktop UX (design only) ​

Method: intentloom.existing-project.adoption.apply.v1

Classification: mutating.

Requirements:

  • Authenticated daemon session (existing token handshake).
  • Explicit capability, exact allowlist arm in daemon and Tauri (is_foundation_method currently denies this method; that is correct until the Apply PR).
  • Params: canonical root, prepared plan, approval receipt (full typed object), preparedPlanId, planDigest. No extra path list. No shell. No generic execution.
  • Daemon resolves canonicalProjectRoot then application re-checks symlink and equality with approval.root / preparedPlan.root.
  • Final revalidation under the root lock before writes.
  • Payload bounded by existing 1 MiB JSON-RPC message limit (maxMessageBytes in packages/daemon/src/index.ts). Oversized request/ response already fails closed.
  • Deterministic error mapping: reuse daemon clientErrorCode / viewmodel reasons (stale-fingerprint, expired, tampered-digest, root-mismatch, …). Add mutation-in-progress only if wait-with-deadline is rejected.
  • Cancellation: if AbortSignal fires before the first write, cancel cleanly. After first write begins, defer cancel until the transaction try/finally completes (commit or rollback). Do not abort between writes. synchronizeGeneratedFiles has no cancel hook today; Apply must not pass a signal into the write loop in a way that leaves a torn tree.
  • Concurrency: per-root lock as in §5.

Desktop UX:

  • Separate Approve (done) and Apply approved plan (new). Do not merge into one click. Separate steps are a security feature, not a problem.
  • Before Apply show: root, plan id/digest, affected file count from preview items with writeEligible, approval status, expiry, no unresolved decisions, explicit warning that project files will change.
  • Apply enabled only after revalidation.status === "valid" and a live approval receipt that still binds that plan. UI validity is not trusted; daemon re-checks.

11. Secrets ​

secretLikePath treats .env, .env.*, and *.key/*.pem/*.p12/*.pfx path segments. Fingerprint and inspect skip those paths. Adoption planning planProjectAdoption also skips them.

Apply must:

  • not include secret-like paths in protocol path lists if they were never adoption targets (they should not be);
  • not hash secret contents into logs;
  • not return previousContent / generated file bodies in the Apply RPC;
  • not overwrite ignored secret-like files (they are not in the generated payload).

Residual: a secret stored at a non-matching path (for example secrets.txt) is not classified. Do not expand secret detection in the Apply PR unless a write target would include it.

12. Twenty gates (before any write) ​

Future Apply must verify, in order, under the per-root lock:

  1. Canonical root resolves to the approved root.
  2. Root is not a symlink substitution.
  3. Prepared plan schema/version is supported.
  4. Prepared plan is unexpired.
  5. Approval is unexpired (approvalValidUntil, equal to plan expiry today).
  6. Approval receipt is structurally valid (parse/validate).
  7. Approval preparedPlanId matches.
  8. Approval planDigest matches.
  9. Approval projectFingerprint matches the live fingerprint.
  10. Approval root matches canonical root.
  11. approvalDigest / approvalId recompute equal (canonical JSON of unsigned fields, same as approve).
  12. approvalSource is local-interactive only.
  13. Plan revalidation status is valid.
  14. Project fingerprint still matches (same as 9; keep an explicit check).
  15. Decisions still validate (invalid-decisions / remaining manual paths).
  16. No blocking diagnostics (blocked-diagnostics).
  17. Affected write paths stay inside canonical root (inside / normalizeStoredPath).
  18. No unsafe symlink on affected paths (safeDestination / pre-walk).
  19. Transaction engine available (synchronizeGeneratedFiles / adoptProject not dry-run).
  20. Rollback capability available for every planned mutation (in-memory backup or create-delete). If collision/noncanonical checks fail, that is fail closed before write.

Any failure: zero writes.

13. Threat matrix ​

ThreatAttack / failure scenarioCurrent mitigationGapRequired controlBlocking for Apply?
Stale planTree changed after prepareRevalidate fingerprint/preview/digestApply not implementedRevalidate under lock immediately before writeno (design into Apply)
Stale approvalClock past approvalValidUntilApprove uses plan expiryApply must re-check clockInjected clock, fail closedno
Root substitutionDifferent directory after pickerDaemon realpath + approve bindRenderer could send another rootCompare approval.root, plan.root, canonical rootno
Symlink swapRoot or dest replaced with symlinkReject symlink root; safeDestinationFingerprint skips symlink filesGate + per-write safeDestinationno
Digest tamperEdited prepared plan JSONDigest/id recompute on revalidate/approveApply must recompute againSame digest functionsno
Approval replayResubmit receiptDuplicate receipts equivalentNo consume DBIdempotent already-applied / stale fail closedno
Concurrent applyTwo RPCs same rootNoneNo root lockPer-root mutexno (in Apply PR)
Partial writeException mid-loopIn-process rollbackRollback can failfailed-incomplete + evidenceno
Daemon crashKill during writeNoneNo journalHonest non-claim; doctor after restartno
Cancel during commitAbort between writesRequest cancel exists on daemon clientTransaction ignores cancelDefer cancel until commit/rollbackno
Post-apply verification failureDoctor error after commitDoctor/diff existReady vs rollback not defined before this reviewCommitted-needs-attention; no auto-rollbackno
Protocol tamperForged method / approvalAuth token, allowlists, parseApply method denied todayExact allowlist + parseno
Oversized payloadHuge plan1 MiB request/response capPrepared plan must fitKeep bound; fail closedno
Secret leakageContents in RPC/logssecretLikePath; diagnostic sanitizationApproved Apply rollback evidence includes previousContentDo not return contentsno
Cross-root path escape../ destinationinside, normalizeStoredPathMust not bypass transactionFail closedno
Filesystem permissionEACCESwrite throws → rollbackPossible incomplete rollbackSurface rollbackFailuresno
Disk fullENOSPCSameSameSameno

No row in this table is a separate security-prerequisite PR. Remaining controls belong in the bounded Apply implementation.

14. Capability matrix ​

CapabilityApplicationProtocolDaemonDesktopReady for Apply?
Final revalidationrevalidateExistingProjectAdoptionPreparedPlanrevalidate RPCyespanelyes — call again under lock
Mutation authorizationtyped approval receipt; generic Approved Apply gate is coarserapproval typesapprove RPC onlyApprove buttonyes — verify receipt on Apply; do not substitute atomic-commit-approval alone
TransactionsynchronizeGeneratedFiles / adoptProjectTransactionResult not yet an adoption-apply viewmodelCLI adopt; no existing-project apply RPCdeny apply methodyes — reuse engine; add RPC
Rollbackin-process backupsAdoptionTransactionOutcomenone for this flownoneyes — handled errors only
Concurrency lockingnonenonenonenoneadd in Apply PR (smallest missing primitive, not a blocker PR)
Replay / idempotencynone for approvalcaller-held receiptnonenoneadd in Apply PR
Post-apply doctordoctorProjectintentloom.doctor.v1yesDoctor viewyes — invoke after commit
Post-apply diffdiffProjectintentloom.project.diff.v1yesDiff viewyes — invoke after commit
Readiness evaluationinspection readiness, doctor errorsinspect/doctoryesinspectyes — compose, do not invent a second Ready engine
Recovery evidencerollbackFailures, failedStage, postWriteValidation codespartial on adopt proposalsanitized diagnosticsnoneyes — return codes/paths, not contents

15. Blocking gaps ​

None that require a separate prerequisite PR before bounded Apply.

Non-blocking residuals that the Apply PR must not paper over:

  • no crash journal;
  • no filesystem CAS;
  • no persistent approval store;
  • generic executeApprovedApplyPlan rollback evidence is content-bearing and must not be used as the Desktop protocol result.

If Apply implementers discover that adoptProject({ dryRun: false }) cannot consume prepared-plan decisions without a small adapter function, that adapter stays in application (map decisions → projectOwnedMappings / documentationMappings) and is part of the same Apply PR — not a second engine.

16. Exact next PR ​

Title direction: feat(adoption): apply approved existing-project plan transactionally

Must:

  1. Application applyExistingProjectAdoptionPreparedPlan (name may vary) implementing the 20 gates, per-root lock (module-level map is acceptable if daemon is the only concurrent mutating client; still serialize inside application so CLI+daemon in-process tests can cover it), then adoptProject / synchronizeGeneratedFiles.
  2. Protocol viewmodel + intentloom.existing-project.adoption.apply.v1.
  3. Daemon mutating capability, dispatch, canonical root, lock around authorize+mutate+doctor+diff.
  4. Tauri exact allowlist arm (no wildcard). Still no shell/FS plugin.
  5. Desktop: summary + separate Apply action; never auto-apply on approve.
  6. Tests: zero writes on each failed gate; rollback; already-applied; concurrent same-root; symlink; stale fingerprint; doctor/diff read-only after commit; apply still denied until allowlisted in that PR’s tests flip from deny to allow.

Must not: Git commit/push; second transaction engine; applyProjectAdoption; consumer-repo audit files; claiming crash atomicity.

References ​

  • ADR-0003 non-destructive adoption
  • ADR-0007 application operation boundary
  • ADR-0008 versioned local protocol
  • ADR-0009 daemon security (1 MiB messages, auth, local IPC)
  • ADR-0053 Approved Apply transaction engine
  • docs/roadmap/DESKTOP_EXISTING_PROJECT_ADOPTION_PLAN.md slices C4–E
  • packages/application/src/existing-project-adoption-approval.ts
  • packages/application/src/existing-project-adoption-prepared-plan-revalidate.ts
  • packages/application/src/index.ts (synchronizeGeneratedFiles, adoptProject, applyProjectAdoption, doctorProject, diffProject)
  • apps/desktop/src-tauri/src/method_allowlist.rs