| file | ARCHITECTURE.md |
|---|---|
| trust | trusted |
| editable_by | human only |
| purpose | The canonical architecture PHARN is built to. The build agent treats this as the spec; the plan agent pins its content-hash; the review agent checks output against it. Elaborates CONSTITUTION.md — never contradicts it. |
Read
CONSTITUTION.mdfirst. This document elaborates it. Principle references (P0–P7) point there and are not restated here (P4).
PHARN is context engineering as a versioned, auditable artifact — compiler discipline (rules →
enforcement → traceability) expressed in prompts (markdown + a few deterministic .cjs/.mjs
helpers consumed by Claude Code), not in application code. The product is the methodology;
transparency is the moat — the client is fully readable, the paid layer is gated by an API
key, never by obfuscation.
Every architectural decision serves one pressure: cover N frameworks × M vendors × K concerns without combinatorial explosion, while staying 100% readable. The central move is to identify axes of change and never let two of them share a file (P3).
There are two threat models (THREAT-MODEL.md): A — does the app PHARN builds defend itself
(OWASP LLM Top 10); B — is PHARN itself injectable as an agent reading hostile context.
A is methodology delivered to the user. B is architecture, and this document bakes it into the
foundation.
Per P0, every guarantee reduces to one of exactly three deterministic primitives. The floor is small, explicit, and non-LLM. Nothing else is a guarantee.
- Hooks — non-LLM programs run by Claude Code at tool boundaries.
pre-write— blocks a write to a protected path (e.g. trusted files; out-of-writes-scope paths).pre-egress— blocks a network call to a domain not on a hardcoded allowlist.
- Content-hash — identity of content, not identity of id. Detects silent mutation of a pinned artifact (spec, seam resolution, fetched doc).
- Enum / regex check — set membership or pattern match, in
validateand at gates (coupling ∈ {...},rule_id ∈ roster, regex for a hardcoded secret).
Rule of reduction: any sentence in this architecture that says "guaranteed" must trace to one
of these three. If it cannot, it is advisory and is labeled so (P0). The honest consequence:
the LLM layer is never injection-proof; only the floor is (LIMITS.md).
A skill, lens, validator, verifier, griller, and auditor are not six kinds — they are one kind
with a role discriminator. Each is a scoped LLM instruction + evals + typed findings. Unifying
them gives one eval runner, one template, one mental model, one community contribution format.
Frontmatter contract (every Capability):
---
name: "<id>"
role: skill | lens | validator | verifier | griller | auditor
kind: pharn-owned | vendor-official | community # also a privilege level — see §5
trust: trusted # the Capability itself is trusted; its INPUT may not be
coupling: agnostic | framework-seam | framework-specific # only on framework-touching capabilities/rules
model_tier: haiku | sonnet | opus
est_tokens: <int> # ADVISORY estimate — see LIMITS.md (cannot be static over variable input)
reads: ["<path-or-artifact>"] # declared inputs — ENFORCED only at the write side by the floor
writes: ["<path>"] # declared outputs — ENFORCED by the pre-write hook (§5, fix #7)
constitution_refs: ["P0".."P7"]
enforces: ["<rule_id>"] # rule IDs this enforces — each MUST have an eval (P1)
related: ["<other capability names>"]
version: "<semver>"
seal: "PHARN ✓ reviewed" # ONLY on kind: pharn-owned
---coupling is the seam classification. It is metadata (advisory; not floor-validated for
correctness — only its enum membership is). Decide it with this top-down procedure, first match
wins. The question is never "what is auth" — it is "what forces this content to change":
- Q1 — Does the content stay byte-identical when you swap the framework (Next → Remix →
SvelteKit) and across SSR/Backend/SPA archetypes? →
agnostic. - Q2 — (else) Does it describe how a cross-framework capability (auth, jobs, fetching, the
Server/Client boundary) wires into this framework's request lifecycle — the capability exists
everywhere, only the wiring differs? →
framework-seam. - Q3 — (else) Is it a primitive that exists only in this framework (nothing to wire
elsewhere — Server Actions, App Router, RSC)? →
framework-specific.
The reframe is the point: "auth" is nothing by itself — its secrets-handling part is agnostic
(SEC), its session-wiring part is framework-seam (AUTH). A half-and-half rule takes the
dominant axis (framework-seam) and stays whole. Worked examples that the procedure must
reproduce: database.md → Q1 → agnostic; auth.md → Q2 → framework-seam; server-actions.md
→ Q3 → framework-specific.
Hooks are not Capabilities. No model_tier, not LLM-invoked. They are the floor (§2). They
must stay a separate class precisely because they are the one layer prompt injection cannot reach
— collapsing them into Capability would erase the most important line in the system.
Dependency-ordered, single root, no sibling imports (P3). Sharing flows only through the
bottom (pharn-contracts).
pharn-contracts L-1 schemas only, ZERO behavior: capability-frontmatter, rule,
finding-shape, severity enum, seam-record, eval-format, trust-fence,
archetype enum, manifest. Everything depends on this.
└─ pharn-core (req) L0–L2 constitution (engine + variants per archetype), agnostic rules,
seam-resolver (the MECHANISM, agnostic), memory-bank, base commands,
universal hooks. A frameworkless lib runs on core alone.
├─ pharn-pipeline spec→plan→grill→build→regress→verify→ship
├─ pharn-review lenses + ledger + cross-model (the GitHub-Action wedge)
├─ pharn-audits deep on-demand audits
├─ pharn-skills-* vendor bridges (db / orm / auth / payments / email) L4
└─ pharn-stack-<fw> framework rules (L3) + seam ANSWERS (ai_docs, phase-variants,
grill-banks, combinations)
pharn-contractsis a separate bottom, not a leaf. This fixes the v1 inversion where the most foundational contract (the rule schema) was owned by a leaf module. The rule schema, the finding shape, the severity enum, the trust-fence format live here.- Tree, with one escape hatch: the tree is the default, not an absolute. A genuinely shared
abstraction may create a cross-edge — but that edge goes through
pharn-contracts(the bottom), never leaf→leaf. This keeps P3 intact and avoids multiplying adapters. - Seam split: the mechanism (resolver) is in core and knows nothing about any framework; the answers (how Drizzle wires under edge runtime in Next) live in the stack pack and know nothing about the process.
Caveat the build agent must respect: in markdown there is no
importstatement to lint, so "no sibling imports" is partly a convention — a sibling reference is a path inreads:or a mention in prose.floor/validate.mjsgreps for forbidden cross-references; beyond that, the review agent enforces it (P3). This is a labeled limit, not a silent guarantee.
Layers meet on named contracts, never ad hoc.
Constitution injection. The constitution text is an immutable prefix on every LLM prompt
([CONSTITUTION] / [TASK] / [RULES]). It is the trusted channel by definition.
Rule-as-SoT + finding shape. Rules are the single source of truth (P4). A finding chains
finding → rule_id → constitution_ref, giving audit-grade traceability — but only if the
rule_id is real and eval-bound (P1; enforced by validate, fix #6). See §8 for the finding
object, which is central to the trust model.
Trust-fence + taint propagation (fix #1, the most important structural fix). Trust is a tag on
the source artifact (P2). Crucially, taint propagates through transformation: when a Capability
processes untrusted input, the free-text fields of its output (problem, evidence) inherit
the untrusted tag. Those fields are never injected as instructions into a downstream stage and
render as quoted/escaped data in PRs and the ledger. The floor-verifiable fields (rule_id,
severity, file:line) are trusted because enum-check / path-resolution produced them. No
guaranteed decision ever rests on a tainted field — it rests on the enum-gated fields (§8). This
is what stops an injected code comment from flipping a guaranteed block.
Seam + seam-record + content-hash. A seam = {name, framework, runtime, packages[], resolution, resolved_via, pinned_at, content_hash}. The agnostic resolver resolves each needed
seam once through a confidence-gated chain (official skill → pinned aidocs → model → fetch+pin →
ask; terminal fallback is ask, P5) and pins it to seam-record.json by commit hash and
content hash. Re-resolve only on a MAJOR bump of a pinned package. A re-fetch that changes
content requires re-review (a diff a human sees) — it never silently replaces. The seam-record
is the only place wiring facts live; a phase says "set the auth session at the seam," the
record says _where, for this project. Seam answers are the single most expensive thing to
maintain (they age with every framework release) — LIMITS.md and the pipeline treat seam
staleness as a first-class, loud signal, not a quiet check.
Archetype + map-consistency (fix #5). archetype ∈ {ssr, backend, spa, lib} (extensible),
detected deterministically — a membership test over two merged sources: package.json
dependency names and structural file-tree signals (.tsx/.jsx → client UI,
next.config.* → ssr, an api/ dir or a route.ts handler → backend). The two signal sets are
merged before the archetype rule runs, so SSR suppression (spa = client-UI ∧ ¬ssr) is computed
once over the union — not a union of per-source archetype sets. Detection stays a pure, deterministic
membership test (P5); the file-tree walk reads names only (never file bodies), and is bounded +
symlink-safe. It drives four independent maps:
constitution variant, which phases run, which grillers run, which plan sections exist. Nothing
ties those four together by default — and in v1 this drifted (a 12-phase plan vs a 10-phase build).
validate therefore checks that all four maps agree on the archetype set.
Eval. {case, expected}, one runner, deterministic-vs-judge per Capability. Evals are
regression suite and spec (P1). validate enforces presence and the rule_id binding.
State. Memory-bank = four canonical markdown files (architecture-context, feature-catalog,
lessons-learned, pattern-library), git-committed. Promotion of a lesson/pattern to canon is a
gated action with provenance per entry (which run / feature / diff) — memory poisoning is
silent and cumulative, so the floor gates the write (P2). Optional gitignored vector index over
lessons + catalog only. seam-record.json and review-ledger are the other durable files. All
state is human-readable canonical markdown.
spec → plan → grill → build → regress → verify → ship. Each stage emits a typed artifact
linking back to the spec:
| stage | artifact | key field |
|---|---|---|
| spec | SPEC.md |
intent (Draft → Approved) |
| plan | PLAN.md |
spec_id + spec_content_hash (fix #4) |
| grill | grill-log | findings vs plan |
| build | build-summary.json |
per-phase results |
| regress | regression-report | regressions outside the feature |
| verify | verify-report | compliance per verifier |
| ship | ship-report | decision + PHARN ✓ reviewed seal |
Keystone: SPEC.md is the root artifact and every downstream artifact carries spec_id.
But spec_id binds identity, not content — so the plan also pins spec_content_hash (fix #4,
reusing the seam-record content-hash mechanism). If the spec is edited after the plan, the hash
diverges and it is detectable, not silent. This is what makes the intent → diff → finding →
decision chain actually audit-grade — and it is the foundation under the 2.0 moat ("the spec is
the one thing only you have"). An audit whose spec content floats under a stable id is not
audit-grade.
Three moments, all reading typed fields (never model prose):
- pre-write — hooks; block before a bad edit lands. Hosts the pre-egress allowlist (a
network call to a non-allowlisted domain does not execute, regardless of whether the model was
fooled) and the constitution/trusted-file write-guard (fix #2) and the
writes-scope guard (fix #7). - in-build — validators; per-phase, gate a wave.
- post-build — lenses (at review), verifiers (at verify), auditors (on-demand). A lens cannot "decide approve" — it emits a typed finding list or nothing.
Two gate kinds (fix #3) — do not conflate them:
- floor-gate — computes a verdict from actual content (regex for a hardcoded secret,
content-hash mismatch, an enum-roster
rule_id). This is the only gate allowed to block a guaranteed invariant. - advisory-gate — reads LLM-assigned
severity. It may escalate or warn; it is never the sole basis for a guaranteed/constitutional block.
The v1 "deterministic threshold gate over LLM severity" was advisory dressed as deterministic — it
is now labeled correctly (LIMITS.md).
floor/validate.mjs (the validate step) enforces, deterministically: capability frontmatter
present; evals present (P1); every enforces rule_id produced by ≥1 eval (P1, fix #6);
coupling enum membership; the four archetype maps agree (fix #5); finding templates separate
enum-gated from free-text fields (fix #1); no forbidden sibling reference (P3, best-effort grep).
Every finding from any Capability has this exact shape. The split between floor-verifiable and tainted free-text fields is the structural expression of fix #1:
finding:
# --- floor-verifiable (trusted: produced by enum-check / path-resolution) ---
type: "<enum>" # FINDING | CONSTITUTION_VIOLATION | ...
rule_id: "<file.md ID>" # MUST exist in the roster AND have an eval (P1, P4)
severity: blocking | important | minor # enum; advisory when LLM-assigned (see fix #3)
file: "<path:line>" # resolves to a real location
# --- tainted free-text (inherits trust of the INPUT; rendered as DATA, never executed) ---
problem: "<one sentence>" # P2: fenced; never injected downstream as instruction
evidence: "<quote/snippet>" # P2: quoted/escaped in PR + ledgerA guaranteed decision (a constitutional block) is computed from the floor-verifiable fields only. The free-text fields are for humans and are treated as untrusted data per P2. An injected comment in reviewed code can at most influence an advisory judgment via the free-text fields — it can never flip a floor-gated block.
Residual (named, not hidden — LIMITS.md): when a downstream LLM stage consumes the
free-text of a finding, "do not execute this as an instruction" becomes a heuristic again. Fix #1
bounds the blast radius (free text never alone gates a guaranteed decision) but does not zero it.
This is the one place the trust model is not provable on paper — and is why attempt 0
targets it (README.md).