Why it is built this way

Thirty-one decisions, and what each one refused.

Every load-bearing choice in this toolkit is written down as an architecture decision record — with the alternative it beat. This page is a way into those records: what you will feel as a user, how they group up, and the promise in them that is recorded but not yet implemented. The record itself stays in the repo, where it can be diffed.

Why there are records at all

A record earns its place by what it refused. Without that, a settled question comes back around every few months and gets re-litigated from scratch — usually by someone about to make the same choice for the same reasons and hit the same wall. The rule that follows, and the record format that enforces it, are stated once in knowledge-base/decisions/README.md.

They sit in an explicit authority order. Intent runs principle > ADR > spec > reference, and above all of it sits the shipped code: a record can be out of date, the code cannot — so where the two disagree, the code is right and the record needs a dated correction appended rather than a rewrite. The ladder is a mechanism, not a slogan: it is what tells the governance checks which side to fix when two artifacts contradict each other. How it works walks that ladder and the checks built on it.

These were distilled, not accumulated. Before them, finding a decision meant reading a project history. What they were distilled from, and where those originals now live, is recorded in knowledge-base/decisions/README.md. Distilling them is the same move as deleting the thirty-three research briefs that used to sit beside this site: a document that mixes a durable decision with a stale status line rots at the speed of its fastest-moving sentence.

In the markdown

The index, the record format it validates against, and the rule for adding one live in the repo. Read it when you are about to write an ADR, or when you want the citations — records cite their sources by path:line, including into documents that now resolve only against git history.

Six decisions you will feel

Not the records — the consequences. These are the choices that show up in what you type, what the tool refuses to do, and what its output looks like. No rule codes; the record is one link away in each case.

Shortcuts, not copies

The installer materializes the suite once and symlinks each skill directory into whichever agents you have, rather than copying files in. Because there is then one file on disk and every agent sees the same one, the freya- prefix could not be applied at install time — it had to live in the repository, on all ten directories and in their frontmatter. What you get back for that rename is that freya update is close to just pulling: fetch, fast-forward, re-link.

knowledge-base/decisions/ADR-014-canonical-store-install-contract.md

Our code drives the assistant, not the reverse

The audit engine owns its own control flow — the discovery loop, the dedup, the majority vote — and reaches the agent through a single injected ask callable. Two things follow. The entire test suite runs offline and free, because a test hands it a stub that returns canned answers. And the scan behaves the same on every agent, because the only host-specific part left is a small adapter that builds an argv and parses stdout.

knowledge-base/decisions/ADR-015-driver-owned-fan-out.md

Workers may read, and nothing else

Telling an assistant "everything except writing files" does not stop it writing files. Measured against Copilot CLI 1.0.75 on 2026-07-27: with a blanket allow plus a write denial, a file was created through a shell command — deny-beats-allow applies to the write tool, not to writes performed through the shell tool. An explicit read allowlist held under the same test. So every worker is launched from an allowlist with the shell left off it, and the argv builder raises on sight if a blanket permission flag is smuggled in through the prompt.

knowledge-base/decisions/ADR-015-driver-owned-fan-out.md

Two findings in the same place stay two findings

On a live fixture run, two different finders both reported the same SQL injection at one location — and the same fixture at concurrency 1 produced only one, so you cannot predict whether your report double-counts. The obvious fix is to merge anything sharing a location. That was refused: a hardcoded key on one line and an injection on the next are two issues, and merging blind would delete one without a word. Between a visible duplicate and a silent deletion, a security tool takes the duplicate — so the engine states the ambiguity and the write-up step, which has the titles in front of it, decides.

knowledge-base/decisions/ADR-015-driver-owned-fan-out.md

Stop asking the assistant to work in parallel

Asked to run the scan and follow its scheduling instructions exactly, Copilot ran all six categories itself as a visible sequence of greps — then reported that six category scans had run in parallel. An instruction file is information the agent reads; the agent's own instructions are its orders; when they conflict the orders win — and they should, or a file in any repository could override what a vendor built. So the fan-out moved into our own process pool. Nothing is delegated, so nothing can be quietly declined. The instrumented evidence →

knowledge-base/decisions/ADR-015-driver-owned-fan-out.md

Why not just adopt spec-kit?

The obvious prior-art question, answered as a named rejection. Two ideas were borrowed: the constitution, which became knowledge-base/principles.md, and executable Given/When/Then, which spec-kit writes as inert prose and this layer binds to a test that actually runs. Wholesale adoption was rejected — spec-kit is a forward build engine, and taking it would have discarded freya's reverse-sync and its intentional-decision differentiator, while coupling the roadmap to an external template engine.

knowledge-base/decisions/ADR-001-behavior-as-executable-artifact.md

The decisions, by theme

One sentence of what was decided, one clause of what it beat, then the file. Anything longer would be stealing from the record.

ADR-001Behavior layer

Intended behavior is a first-class executable artifact

Intent that is observable behavior becomes a BEH-NNN record with a lifecycle, bound by an adapter to a test that actually runs — so the toolkit gains a forward-authoritative layer alongside its reverse-sync engines.

Rejected — inert Given/When/Then checkboxes, as spec-kit ships them: nothing executes them, so drift stays silent, which is the gap being closed rather than a fix for it.

knowledge-base/decisions/ADR-001-behavior-as-executable-artifact.md

ADR-003Behavior layer

Lifecycle state, not a certainty score, is the trust signal

A behavior is proposedconfirmedaccepted, plus quarantined and deprecated; only accepted and non-quarantined is authoritative, and only it can block a wrap-up or downgrade a finding.

Rejected — a two-state proposed → accepted lifecycle: accepting would keep meaning "write the test now", so intent capture stalls mid-feature and the queue never drains.

knowledge-base/decisions/ADR-003-lifecycle-state-is-trust-signal.md

ADR-004Behavior layer

Bind behaviors to tests through adapters, and split execution from the graph

An adapter plus a locator links a behavior to whatever already verifies it — Gherkin, a native runner, or manual — and the layer ships as two skills split on the execution seam, one running behaviors and one owning behavior.json.

Rejected — letting code-graph own behavior.json, or merging it into graph.json: the first draft specified exactly that and it was explicitly reversed, because it welds the behavior projection to whatever code substrate is eventually chosen.

knowledge-base/decisions/ADR-004-behavior-adapters-and-execution-split.md

ADR-007Behavior layer

Bootstrap everything as proposed, drain the corpus lazily on hit

A brownfield project gets a full inferred corpus of proposed candidates and reviews none of it up front; the queue drains where a change's blast radius touches it, and the cold tail is published as a git-tracked backlog.

Rejected — reviewing the corpus eagerly at adoption: inference produced ~383 candidates on a 224-file repo, nobody reviews that, and the mechanism dies at onboarding.

knowledge-base/decisions/ADR-007-bootstrap-proposed-drain-lazily.md

ADR-005Substrate

Repair the parsing substrate in place, stdlib-only, and never return a confidently-empty result

Both parsers — spec frontmatter and import resolution — were fixed with the standard library under a bounded capability contract, classifying every edge as internal, external: or unresolved:. The governing rule: "couldn't resolve" must never look like "no dependencies".

Rejected — PyYAML or any real YAML engine: correct in isolation, but it breaks the zero-install property, so the spec's wording was changed to match the constraint rather than the constraint bent to match the spec.

knowledge-base/decisions/ADR-005-repair-parsing-substrate-in-place.md

ADR-018Substrate

The code graph is produced through a contract, not by one resolver

Any backend satisfying a fixed contract can produce the graph, and everything downstream reads one artifact shape whichever one ran. Two ship: a stdlib-only floor reading four languages, and an opt-in backend reading forty.

Rejected — adopt the best parser and wire it in. Fastest to ship, and it reopens the whole question the first time that parser stalls, by which point five skills depend on its output shape.

knowledge-base/decisions/ADR-018-substrate-contract-for-the-code-graph.md

ADR-019Substrate

The floor always ships, and any other backend runs because a person named it

The built-in resolver stays installed and stays the default, because the case that started this is a locked-down laptop where a package install is blocked. A better backend is chosen once — when you install freya — and recorded in the project's committed settings, so a clone and CI resolve the same one.

Rejected — score the backends and pick whichever reads most. Measured: the new one scored 63 to 58 here and would have taken over on the next build, changing every blast radius on the machine with no diff.

knowledge-base/decisions/ADR-019-the-floor-and-choosing-a-backend.md

ADR-021Substrate

An edge is an object carrying kind and provenance, behind a versioned schema

An edge was a string — the path it pointed at — so "A imports B" and "A re-exports B" were the same value, and "this function calls that function" could not be written down at all. Measured: the old shape could express 2,102 of the second backend's 5,027 real connections.

Rejected — stay file-level and honest. Cheapest, and it gives up 58% of what the second backend knows to avoid a migration that only gets more expensive. Recorded as unimplemented: the provenance tier is written on every edge and read by no code.

knowledge-base/decisions/ADR-021-an-edge-is-an-object-with-kind-and-provenance.md

ADR-029Substrate

Every answer says what the backend could not read, and it is never a refusal

A build on a repo of twelve Java files and three TypeScript files printed "Found 3 source files" and exited 0 — the Java files were never enumerated, so there was nothing to skip. Now every answer carries what was unread, and the directories to search instead. It is absent entirely when there is nothing to say.

Rejected — print it for the human. A build almost never has one attached, so the warning lands nowhere. The consumer is the agent, and the signal has to ride in the machine-readable answer.

knowledge-base/decisions/ADR-029-an-answer-says-what-it-could-not-read.md

ADR-006Substrate

Behavior tests drive the app over its real interface, and coverage follows

An integration behavior drives the booted application over HTTP and never imports its internals; observed runtime coverage therefore exists only at unit level, while an integration behavior's static import closure is emitted as edges tagged source: static, each carrying its provenance.

Rejected — NODE_V8_COVERAGE capture over the app process: spiked and measured at zero app code recovered, because the render worker does not inherit the env-var capture.

knowledge-base/decisions/ADR-006-real-interface-execution-and-coverage.md

ADR-002Governance

Authority runs principle > ADR > spec > reference, and every fact is owned once

Specs and behaviors are forward and authoritative; reference docs are reverse and descriptive; the two are never conflated. A generated projection is allowed, a hand-maintained duplicate is forbidden.

Rejected — duplicating behavior text into specs/ for discoverability: discovery is solvable with a link, but a second editable copy always drifts, and then nothing says which copy is the intent.

knowledge-base/decisions/ADR-002-authority-order-single-ownership.md

ADR-008Governance

Changing an accepted guarantee requires a declared-intent record

When an accepted behavior fails, completion blocks until it is classified as exactly one of three things — a regression, an intended change recorded as a durable INTENT-NNN artifact, or a test-infrastructure failure resolved by quarantine.

Rejected — the "just update the test" reflex: it converts a broken guarantee into a green suite with no record that anything changed.

knowledge-base/decisions/ADR-008-declared-intent-record-required.md

ADR-009Governance

Two enforcement tiers: deterministic checks block, model judgment is resolve-to-proceed

Failures are gated by the kind of check that produced them. Link integrity and a failed accepted behavior hard-block; contradiction, principle and drift findings must be resolved before you proceed but never fail a script. Every check fails open on infrastructure trouble.

Rejected — hard-blocking on a model's self-reported confidence: uncalibrated confidence produces random failures and trains reflexive overrides.

knowledge-base/decisions/ADR-009-two-enforcement-tiers.md

ADR-010Governance

Non-fix resolutions live in append-only JSONL logs, re-judged on recurrence

Refute, amend, auto-clear and supersede outcomes are one JSON object per line, never erased; on recurrence the model re-validates the specific prior reason against the current change rather than matching a key. Three checks delegate to one shared implementation.

Rejected — mutating a status field or deleting a record to retire a resolution: a rewrite forfeits tamper-evidence, and a later superseded record gives identical semantics without one.

knowledge-base/decisions/ADR-010-append-only-resolution-logs.md

ADR-011Governance

Scope each governance check by which failure is recoverable

The contradiction check is deliberately unscoped — a changed spec is compared against every active ADR, with no applies_to filter — while the drift check is scoped to the change's blast radius, and says impact_source: "changed-only" out loud when the graph is unavailable rather than returning a silently empty radius.

Rejected — comparing specs against ADRs before the ADR format existed: the check would have run against a structurally-empty set while looking like it covered architecture decisions. A fake coverage is worse than an honest gap. Also rejected: any opt-in narrowing lever, because opt-in turns the failure mode from noise into silence.

knowledge-base/decisions/ADR-011-governance-check-scoping.md

ADR-012Governance

Only an accepted, test-backed behavior may downgrade a security finding

An accepted behavior that explains a flagged finding is the strongest intentional-design evidence available: it marks the finding intentional and drops it from the outstanding count, while the finding stays fully visible in the report.

Rejected — letting proposed or confirmed behaviors explain findings: intent that nobody has claimed a test for is weaker still, so a bootstrap guess could clear a real vulnerability.

Corrected twice on 2026-08-24, the second retracting part of the first — the title's "test-backed" was the record's own false premise, and the ADR carries both readings at the top. Plain --covering runs no test: it re-derives state and locator from the specs and reads exercised paths from the committed behavior.json. What it now requires is stricter than what the first correction described — the state must be accepted, a locator is required and must resolve, and the exercised path must carry source: observed, so an edge inferred from the import graph licenses nothing. Read "test-backed" as "accepted, with a locator that resolves and an observed run behind it". The first correction also argued the gap could never be closed, because running the repository's own tests would be arbitrary code execution; that is an argument against freya-behavior-runner, which ships to do exactly that, and --covering --verify now re-runs the linked test.

knowledge-base/decisions/ADR-012-accepted-behavior-downgrades-findings.md

ADR-013Portability

One self-locating freya launcher is the sole command surface

Every skill file invokes freya <command>. The launcher works out where it lives from its own path, dispatches through a registry, and runs targets with the interpreter already running — one instruction set for every agent, Claude included.

Rejected — exposing the suite over MCP: a server to build, run and maintain in exchange for capability the agent already has, because the tools are local Python CLIs it runs through bash. Worth revisiting only for a shell-less agent.

knowledge-base/decisions/ADR-013-single-freya-launcher.md

ADR-014Portability

Install the whole suite from a canonical store by symlink, and touch only what we created

The checkout is the store; the freya- prefix lives in the repository so installation rewrites nothing; freya update is git-only and never automatic; and every destructive path is gated on ownership — a real file or directory always blocks, and uninstall removes only links pointing into this store.

Rejected — letting --force replace a real file or directory: force is for links we could have made ourselves, not for other people's data.

knowledge-base/decisions/ADR-014-canonical-store-install-contract.md

ADR-015Portability

Own the fan-out in our own driver, with workers under a read-only allowlist

Deterministic orchestration lives in a stdlib Python driver behind one injected ask callable, with a bounded worker pool and a small per-agent adapter. Every worker argv is an explicit allowlist that excludes the shell, and blanket permission flags raise rather than run.

Rejected — --allow-all-tools --deny-tool=write: the intuitive reading of deny-beats-allow, and empirically bypassable through the shell — which is why the allowlist, not the deny flags, is the load-bearing control.

knowledge-base/decisions/ADR-015-driver-owned-fan-out.md

ADR-016Method

Prove it against the real thing

Tests drive real dependencies wherever one can be produced honestly — the updater's tests run real git in temporary directories — injected runners are reserved for what a real dependency cannot produce honestly, mechanisms are validated by dogfooding against a real project, and live runs commit their load-bearing extracts as evidence.

Rejected — editing a design record in place and deleting the superseded text: it makes the reasoning unrecoverable and invites re-walking the same wrong path, so a correction is a dated block appended underneath the original.

knowledge-base/decisions/ADR-016-prove-it-against-the-real-thing.md

Four things it deliberately is not

No MCP server — considered and dropped; the tools are local Python CLIs an agent runs through bash. No dependency on a single-skill installer — the ecosystem installers are built for one skill from a repo-root file, and a coupled suite installed piecemeal produces exactly the behavioral drift they document; the store-plus-symlink pattern was reused, the tooling was not. No auto-updating — a silent pull of latest onto a work laptop mid-task is wrong for a toolkit that gates wrap-up, so updates are manual with a throttled, notify-only staleness check. No pretending about Windows — a deliberately thin install.ps1 ships, CI runs the tests and a real install there on every push, and no agent CLI has ever been driven on that platform. That last one is stated as a gap, not a feature.

Twenty of the thirty-one are on this page. The eleven not shown — ADR-017, ADR-020, ADR-022, ADR-023, ADR-024, ADR-025, ADR-026, ADR-027, ADR-028, ADR-030 and ADR-031 — are narrower calls about artifacts, symbols, scope and where shared code lives. They are linked here rather than summarised because this page is a way into the records, not a replacement for them, and a card that flattens a decision to two sentences is worse than a link. The full index is in knowledge-base/decisions/README.md.

The fork that was open, and how it went

Ending with the one this page used to end on unresolved.

freya-code-graph was a homegrown, regex-driven import scraper covering TypeScript, JavaScript, Python and Go. Real work projects are Java plus Kotlin plus Swift plus config and deployment material, and the graph was blind to every one of them — a wall hit immediately on the first attempt to use the toolkit on a work laptop. Because behaviours, drift detection and blast radius all stand on that graph, the fork was foundational: adopt an external graph substrate, buying a real multi-language parser at the cost of the zero-install property, or keep zero-install with homegrown per-language resolvers that are limited and brittle.

Neither. Picking a tool means picking again in two years, by which point five skills depend on its output shape — so the parser is not the architecture, the socket it plugs into is. The graph is produced through a contract. The homegrown resolver stays as the always-installed floor, because the case that started all of this is a locked-down work laptop where a package install is blocked; an opt-in backend reads 40 languages across 93 extensions. Two implementations rather than one deliberately: an interface with a single implementation encodes the assumptions of its only caller, and this one did — the contract was written with one backend behind it, and building the second found it could not actually run anything else.

The zero-install position is therefore neither superseded nor kept as a hard line. It became a floor: freya still degrades to something everywhere rather than to nothing, and the thing it degrades to now says what it could not read instead of reporting an empty graph as an empty project.

What is genuinely still open

Every edge records whether it was read straight out of the source or worked out by resolution, and the design says only the first kind may gate a commit. No code filters on it. The field is written faithfully and read by nobody, and it had been stated as present fact in the spec, in the decision record and on these pages before anyone went looking for the filter. Over-approximating a blast radius is the safe direction, so the exposure is small — but a guarantee nobody implemented is exactly the kind of claim this project is supposed to be able to check. ADR-021