Constructdocs
section · decisions

0015 Affirm Hybrid Architecture

10 min read·decisions / adr / 0015-affirm-hybrid-architecture

ADR-0015: Affirm the hybrid markdown + deterministic-enforcement architecture

  • Date: 2026-06-03
  • Status: proposed
  • Deciders: Gerald Dagher (owner)
  • Supersedes: none

Problem

Construct steers agent work through two very different kinds of artifact at once: a large natural-language corpus (one orchestrator persona, 28 specialist prompts, 150 skill files, 46 rule files, 4 profiles) and a body of executable code that blocks or shapes tool calls (37 hooks, handoff contracts, role fences, linters). As that corpus grows, a recurring doubt surfaces: is steering agents with markdown the correct foundation, or should the system migrate to a "policy engine"? The doubt is decision-forcing because the answer dictates where future investment goes — into authoring more markdown, into rewriting the soft layer as code, or into closing specific gaps in what already exists. Left unresolved, the corpus keeps growing without a stated position on what markdown is for versus what code must guarantee, and the team cannot tell whether the system is fully implemented or merely documented.

Context

Forces bounding the decision:

  • The two layers already coexist and are load-bearing. This is not a greenfield "markdown vs. engine" choice; both are in production and tested. Any decision must account for sunk, working implementation rather than a clean slate.
  • The field has converged on hybrid, not on either pole. Anthropic's own guidance — see Building Effective AI Agents (2024-12-19, anthropic.com) and Effective context engineering for AI agents (2025-09-29, anthropic.com) — favors simple composable patterns plus just-in-time/progressive disclosure of context, not a monolithic framework and not unbounded prompt text. Equipping agents for the real world with Agent Skills (anthropic.com) makes progressive disclosure — metadata preloaded, bodies loaded on demand — the core design pattern, and Agent Skills became an open standard on 2025-12-18.
  • Prior ADRs constrain the shape. ADR-0013 fixes the on-disk skills layout and ADR-0002 the layered architecture; the soft corpus is already designed to be compiled, not consumed raw.
  • The no-fabrication rule applies to this document. Every claim below traces to a file:line, a named test, or a verified URL.

Decision

Affirm the hybrid architecture as the long-term foundation. Markdown is the human-authored, version-controlled, progressively-disclosed guidance and audit surface; code hooks, contracts, fences, and linters are the non-bypassable enforcement surface. We do not migrate to a pure policy engine, and we do not stay markdown-only. Future work targets the seams between the two layers (named in the gap register), not a rewrite of either.

Rationale

1. The framing is a false binary — Construct is already a policy engine and a markdown system. The deterministic layer is real and verified end-to-end, not aspirational:

  • All hard tool-call gates block with exit 2 and honor no skip-vars. Verified by test and by isolated live invocation: tests/policy-engine.test.mjs (bootstrap + Stop/drive gates), tests/hook-scan-secrets.test.mjs, tests/hooks/config-protection.test.mjs, tests/hooks/pre-push-gate-prior-ci.test.mjs (explicitly asserts CONSTRUCT_SKIP_*/CONSTRUCT_ALLOW_* are ignored), tests/comment-lint.test.mjs. Wiring confirmed in claude/settings.template.json (PreToolUse/PostToolUse/Stop matchers).
  • Handoff contracts enforce at runtime: a failing executable postcondition returns BLOCKED_CONTRACT (lib/contracts/validate.mjs:262), invoked at dispatch via lib/mcp/tools/workflow.mjs:117 and at write via bin/construct:1668. Covered by tests/functional/w2-contract-enforcement.functional.test.mjs and tests/functional/binary-postcondition-enforcement.functional.test.mjs.
  • Intent classification is a deterministic pure function with no inline LLM (lib/intake/classify.mjs:16), calibrated to ECE < 0.10 (tests/intake-classifier-calibration.test.mjs).

A system with non-bypassable mutation gates, runtime-validated handoffs, RBAC-style role fences, and an append-only audit trail is a policy engine. The open question was never "build one"; it was "is the one we have complete." It is largely complete (see audit) with named, bounded gaps.

2. Markdown is doing the job the field says it should — and only that job. Progressive disclosure is enforced, not assumed: PROMPT_WORD_CAP = 3600 words per prompt (lib/role-preload.mjs:22) is enforced at sync with a hard exit(1) on overshoot (scripts/sync-specialists.mjs); skills ship as separate SKILL.md entries fetched on demand via get_skill, with zero registry entries forcing preload. Inlining all skills into every agent would be roughly two orders of magnitude larger per agent — exactly the context-dilution failure the just-in-time pattern exists to avoid. This matches Anthropic's Agent Skills model directly. Replacing markdown with code would discard the audit/readability/version-control benefits that the field now treats as the point of the markdown layer.

3. The soft/hard split maps cleanly onto what each layer is good at. Code enforces what is mechanically checkable and safety-critical (secrets, destructive shell, config tampering, branch/CI discipline, artifact structure, handoff schema). Markdown carries judgment that resists mechanical checking (framing discipline, research quality, persona perspective). This is the same division of labor the Anthropic context-engineering guidance recommends.

Completeness audit (evidence)

Each mechanism was traced to a test and/or a live isolated invocation. Status legend: VERIFIED (passing test or reproduced live block), PARTIAL (works but with a stated coverage limit), UNVERIFIED.

MechanismLayerClaimed behaviorEvidenceStatus
Bootstrap gatehookBlock Edit/Write until groundedtests/policy-engine.test.mjs; live exit 2VERIFIED
Drive-mode Stop gatehookBlock Stop on unmet criteriatests/policy-engine.test.mjs; live exit 2VERIFIED
guard-bashhookBlock rm -rf /, force-push to main, DDL, fork bombslive exit 2; claude/settings.template.jsonVERIFIED
block-no-verifyhookBlock git commit/push --no-verifylive exit 2VERIFIED
pre-push-gatehookBlock rejected-SHA repush; refuse claude/* push; ignore skip-varstests/hooks/pre-push-gate-prior-ci.test.mjs (7/7)VERIFIED
config-protectionhookBlock edits to tracked lint/format configstests/hooks/config-protection.test.mjs (4/4); live exit 2VERIFIED
edit-guardhookBlock stale old_string; enforce role fencelive exit 2; lib/roles/fence.mjsVERIFIED
scan-secretshookBlock real API keys/tokenstests/hook-scan-secrets.test.mjs (11/11)VERIFIED
comment-lint (code)hook+lintBlock banned comment patterns in codetests/comment-lint.test.mjs (23/23)VERIFIED
json-validatehookBlock malformed .json writesinline in settings.template.json; live exit 2VERIFIED
No-skip-vars invariantpolicyNo CONSTRUCT_SKIP_* honored on gatestests/hooks/no-skip-vars.test.mjs; tests/hooks/pre-push-gate-prior-ci.test.mjsVERIFIED
Contract handoff (executable)contractFailing postcondition ⇒ BLOCKED_CONTRACTlib/contracts/validate.mjs:262; tests/functional/w2-contract-enforcement.functional.test.mjsVERIFIED
Contract postconditions (prose)contract85 of 106 postconditions are proseparsed specialists/contracts.json (40 contracts; 85 prose, 21 executable)PARTIAL — prose is advisory, not code-enforced
Artifact no-fabricationlintWarn at write-time, block at release gatelib/hooks/comment-lint.mjs; bin/construct:4019 (CONSTRUCT_ARTIFACT_LINT_MODE=block); tests/functional/release-gate.functional.test.mjsVERIFIED
Citation requirementlintNumeric/mind-reading claims need a citationlib/comment-lint.mjs:217PARTIAL — checks marker presence only, not source validity
Intent classifiercodeDeterministic, no LLM, calibratedlib/intake/classify.mjs:16; tests/intake-classifier-calibration.test.mjsVERIFIED
Orchestration routingcodeDeterministic dispatch from classificationtests/orchestration-policy.test.mjsVERIFIED
Progressive disclosuresyncHard 3600-word cap; skills on-demandlib/role-preload.mjs:22; scripts/sync-specialists.mjs exit(1)VERIFIED
Telemetry loopruntimeVendor-agnostic; traces read back to optimizelib/telemetry/client.mjs; lib/evaluator-optimizer.mjs; tests/functional/loop-closure.functional.test.mjs, a4-optimize-gate.functional.test.mjsVERIFIED — loop closed; auto-trigger is manual/scheduled
RRF hybrid searchstorageDeterministic rank fusiontests/storage-rrf.test.mjs, tests/retrieval-fusion.test.mjsVERIFIED

Verdict on "fully implemented": the enforcement layer fires as documented. The only mechanisms that fall short of their stated intent are the two marked PARTIAL — both are enforcement-strength limits, not broken features. Everything else is verified by a named test or a reproduced live block.

Field comparison

ProjectPatternOverlap with ConstructWhat it does that Construct does not
Anthropic Agent Skills / Claude Code (hooks, subagents)Markdown skills with progressive disclosure + deterministic hooksNearly 1:1 — this is the substrate Construct compiles toSkills are now an open standard with a partner directory; Construct's corpus is bespoke
CrewAIRole/Goal/Backstory personas in codePersona specialization + handoffsPython-native execution runtime
LangGraphExplicit state graph + checkpointingMulti-step orchestrationFirst-class durable checkpoint/resume of long-horizon state
OpenAI Agents SDKAgents + handoffs + guardrails + built-in tracingHandoffs + guardrails + telemetryTracing/observability is a built-in default surface
Cursor rules / AGENTS.mdRepo-local markdown behavior rulesMarkdown-as-interface, version-controlledFlat/shallow; Construct adds compilation, linting, search
NeMo Guardrails / OPADeclarative policy DSL enforced at runtimeDeterministic, non-bypassable gatesPolicy expressed declaratively and (for OPA) decoupled/reusable, vs. Construct's hand-written hooks
Spec-Kit / BMADSpec-first, full-lifecycle persona teamsPersona taxonomy + structured artifactsTargets requirements→code lifecycle end to end

Alignment is strong on the dimensions the field now treats as best practice: hybrid soft+hard, progressive disclosure, persona specialization, hybrid retrieval, deterministic gates, vendor-agnostic telemetry. Where comparables lead is in generalized mechanisms — declarative policy (OPA/NeMo), durable checkpointing (LangGraph), and tracing-as-default (OpenAI SDK).

Rejected alternatives

  • Migrate to a pure policy engine (rewrite the soft corpus as code/DSL). Rejected: it would discard the human-readable, version-controlled audit surface the field explicitly values, and most of what the corpus carries (framing judgment, persona perspective, research discipline) is not mechanically checkable — encoding it as policy would either lose fidelity or reinvent prompts inside a worse authoring format. The deterministic gates that can be code already are.
  • Stay markdown-only (lean on prompts, drop or freeze the hook/contract layer). Rejected: prompts are bypassable by construction; the audit shows the hard gates (secrets, destructive shell, branch/CI discipline, handoff schema) catch exactly the failures prompts cannot be trusted to prevent. The field's documented failure mode for large prompt-only systems is instruction dilution; removing the enforcement layer would expose Construct to it.
  • Adopt a generic guardrails framework (NeMo Guardrails / OPA) wholesale. Rejected as a replacement; retained as an influence. Construct's gates are tightly coupled to Claude Code's hook protocol and its own contract schema; swapping in a general engine now adds integration surface without removing a current pain. The declarative-policy idea is captured in the gap register as a possible evolution, not a migration.

Consequences

  • Locked in: the compile-from-markdown pipeline (scripts/sync-specialists.mjs), the hook protocol coupling to Claude Code, and the soft/hard division of labor. The team commits to authoring guidance in markdown and guarantees in code, and to keeping the two reconciled at sync time.
  • Easier: reasoning about where a given behavior should live (judgment → markdown; guarantee → hook/contract), and onboarding (the audit table is a map of what is actually enforced).
  • Harder / new constraint: every new "rule" now carries a placement decision and, if it is a guarantee, an enforcement obligation — prose in a contract is explicitly not enforcement (see gap register), so authors cannot assume writing it down makes it true.

Gap register (follow-ups — not filed or implemented here)

  1. Citation validity, not just marker presence (highest priority). lib/comment-lint.mjs:217 accepts any [source: …], URL, or [^n] marker near a claim without checking the source exists or supports the claim. An agent can satisfy the gate with a fabricated reference. This is the sharpest gap between the no-fabrication rule and its enforcement. Candidate fix: resolve [source:] paths and [^n] footnotes and fail when the target is missing.
  2. Prose postconditions are unenforced. 85 of 106 contract postconditions are advisory text (specialists/contracts.json). Candidate: convert the mechanically-checkable ones to executable checks and extend lib/contracts/validate.mjs beyond its current three check types.
  3. No explicit instruction-conflict resolution. No declared precedence when rules/skills conflict; linters detect, they do not arbitrate.
  4. Optimize loop is closed but manually triggered. lib/evaluator-optimizer.mjs reads traces and proposes patches (gated, dry-run by default), but nothing schedules it. Candidate: a scheduled run.
  5. Declarative-policy evolution (optional). Some hand-written hook logic could move to a declarative policy representation (OPA/NeMo-style) for reuse and testability — an evolution to weigh, not a migration to commit to.

Reversibility

Two-way door on the gaps (each is an additive change to existing code). Effectively one-way on the core decision: the compiled-markdown + hook architecture is now load-bearing across every platform Construct syncs to, and reversing it would mean rewriting the soft corpus and re-homing every guarantee. Revisit only if the platform substrate (Claude Code hooks / Agent Skills standard) changes shape, or if instruction dilution becomes measurable despite the enforced word cap.

References