Contracts

adversarial-evidence-contract.md

Contract: adversarial-evidence disposition

Every contested finding raised by a profile-loaded adversarial squad against this mission is recorded with exactly one disposition; none is silently dropped.

  • accepted — the finding changed the spec/plan; cite where.
  • changed — a modified version was adopted; cite the modification.
  • deferred_with_rationale — not adopted now; record why and where it is tracked (e.g. Non-Goal, follow-up issue).

The live ledger for this mission is in ../research.md §"Adversarial Evidence Ledger", covering the pre-spec squad (architect / paula / debugger / renata) and the post-spec squad (renata / paula / priti). Any new adversarial pass (post-tasks, pre-merge) appends rows there.

decisions-doctor.md

Contract: decisions-index reconciler (spec-kitty doctor subcommand)

Surface: src/specify_cli/cli/commands/_decisions_doctor.py, mirroring the _mission_state_doctor.py extraction shape PR #4813 establishes (structure only).

Canonical authority (corrected)

The reconciler rebuilds decisions/index.json from the decision event log, applying the decisions forward event→IndexEntry mapping — NOT status.reducer.reduce() (that reduces a different spec_kitty_events status-lane schema and cannot be reused here).

Single fold, no second reducer. Today the forward writer (decisions/service.py open_decision/_terminal_command) builds the IndexEntry from CLI params and derives the event from it (emit_decision_opened(..., entry=entry)). The reconciler needs the inverse event→IndexEntry fold. To avoid a second source of truth for "what a decision event means":

the forward path is refactored to share the same mapping, so they cannot drift).

  • Factor one canonical event → IndexEntry fold and have the reconciler consume it (ideally
  • The reconciler must not hand-roll an independent interpretation of the opened/resolved payloads.

Command

spec-kitty doctor decisions --mission <handle> [--json] [--repair]

entries with no backing event).

under the sidecar index.json.lock. No-op (exit 0, "clean", no write) when they already agree.

  • Diagnose (default): read-only; report entries in the log missing from the index (and index
  • --repair: rebuild index.json from the log via the canonical fold, written atomically

Invariants

DecisionPointOpened + resolved/terminal events round-trip every IndexEntry field the index needs (no field reconstructed wrong or dropped on --repair). If a field does not round-trip today, that is a prerequisite fix, surfaced before the reconciler is trusted.

  • I9 (index reconstructible from the log) must be proven, not asserted: a task asserts the
  • Repair never invents entries absent from the log, never drops log-backed entries.
  • Runs under the same sidecar lock as the write path (I8) so a concurrent open/resolve cannot race.

Acceptance

  • Round-trip test: every IndexEntry field is recoverable from the opened+resolved events (proves I9).
  • Seed a diverged corpus (log=8, index=5) → --repair yields index==log==8 (SC-002).
  • Repair on an agreeing corpus is a no-op; --json output is stable/machine-readable.

no-follow-open.md

Contract: canonical no-follow module (kernel.no_follow)

Layer: src/kernel/ (so kernel.locks may consume it without violating kernel ↛ specify_cli).

Module hoist (whole module, not just the open primitive)

The existing src/specify_cli/core/no_follow.py is hoisted in full to src/kernel/no_follow.py. specify_cli.core.no_follow becomes a thin re-export (from kernel.no_follow import *) with an __all__ identical to today's, so all existing importers need zero edits.

Full preserved public API (all six symbols; identity preserved): NoFollowPathError, chmod_fd, fd_relative_dir_ops_supported, open_no_follow, read_text_no_follow, write_text_no_follow.

except NoFollowPathError at specify_cli/git_common/gitignore_manager.py:18 (and peers) still catches.

  • NoFollowPathError must remain a single class object re-exported (not redefined), so
  • open_no_follow(path, flags, mode=0o666) keeps its exact signature.

Behavior (open_no_follow)

surfaced as NoFollowPathError (does not follow, does not truncate, does not exit-0).

semantics; documented residual risk (NFR-001 POSIX-scoped).

explicitly; the shared lock does not — C-003).

  • Adds O_NOFOLLOW (POSIX) to the flags; a symlink at the final component raises ELOOP →
  • Windows: O_NOFOLLOW resolves to 0 (getattr(os, "O_NOFOLLOW", 0)) — degrades to current
  • Does not add O_EXCL by default (fresh-create callers, e.g. credential temp writes, pass it

Consumers routed through this primitive (the 4 defect write-sites + the lock)

kernel.locks._LockCore.open_fd, kernel.locks.force_release, zeitgeist_client/credentials.py, tracker/credentials.py, migration/mission_state.py (its lock), runtime/next/_tmp_namespace.py, runtime/asset_preparation.py (sentinel). Scope note: this mission routes the lock + these defect sites through the canonical helper. The ~7 already-correct pre-existing hand-rolled O_NOFOLLOW sites elsewhere are NOT in scope (see spec Non-Goals / research D1 census).

Acceptance

raises NoFollowPathError (never exit-0). Tested per consumer path (SC-001), isolated HOME.

(coordination/atomic_write, git_common/gitignore_manager, intake/brief_writer, session_presence/writers/markdown_rules, skills/{command_installer,installer,manifest_store}, tool_surface/{bundles/projection, providers/agent_profiles, providers/session_presence}, upgrade/migrations/m_3_2_8) in addition to the 13 kernel.locks consumers.

  • A planted symlink at the target path leaves the pointed-at file's bytes unchanged AND the call
  • Repoint blast radius: WP01 acceptance runs the 11 specify_cli.core.no_follow importers
  • tests/architectural/test_layer_rules.py and test_lock_primitive_ban.py stay green.