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.
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.
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.
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.
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 →
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.
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
Lifecycle state, not a certainty score, is the trust signal
A behavior is proposed → confirmed → accepted,
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
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
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
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
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
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
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
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
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
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
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
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.
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
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
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
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.
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
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.
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
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.
- ADR-018 — a contract, not a tool
- ADR-019 — the floor, and choosing a backend
- ADR-029 — an answer says what it could not read
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