qed — Implementation Specification v0.3.1¶
Status: Ready for implementation · 4 Oct 2026 (consistency review applied)
Name: product Valqore QED; qed is the command, the import package and the repository (§24.1, closed 2026-10-08 — no longer a placeholder)
Supersedes: the planning-phase architecture draft (v0.2, kept privately); incorporates DESIGN_DECISIONS.md ADR-001…008 (all ACCEPTED)
Scope: this document specifies the product. Commercial planning — pricing, funding, positioning against named competitors — is deliberately not here; it lives in a private planning repository and is not part of the Apache-2.0 distribution.
Language: Python 3.12+ · License: Apache-2.0 (kernel), AGPL-3.0 (UI), proprietary (cloud)
0. How to use this document¶
This is both the architecture reference and the prompt for whoever (human or coding agent) implements qed. Read §1–§3 fully before writing code. Implement in the order of §22. Every section marked MUST is a hard requirement; SHOULD is a strong default; MAY is optional.
Prompt preamble for a coding agent:
You are implementing
qed, a self-hosted, vendor-neutral policy + evidence kernel for AI-agent actions. Followdocs/SPEC.mdexactly. The kernel's decision path MUST contain no LLM calls and no network calls (qed.core.*imports no HTTP client; a test enforces this). Every decision MUST be deterministic and replayable. Every evidence record MUST be Ed25519-signed and hash-chained with akey_idandschema_version. Do not add dependencies beyond §20.3 without updating this spec. Prefer small pure functions, Pydantic v2 models, and explicit types. When the spec is silent, choose the simplest option that keeps the decision path pure, and record the choice indocs/adr/.
1. Mission, scope, non-goals¶
1.1 One sentence¶
An AI agent proposes an action → qed verifies who it acts for and with what budget → deterministic rules decide ALLOW / BLOCK / REQUIRE_APPROVAL → a human approves if required → every step is written to a tamper-evident, independently verifiable evidence chain exportable as OSCAL.
1.2 In scope (v1)¶
- Kernel: delegation verification, policy engine, budget ledger, approval service, evidence ledger, credential broker, mandate registry.
- Adapters: MCP proxy (stdio + HTTP/SSE), Claude Code / Codex hooks, HTTP forward proxy, Python SDK, CI command.
- Exports: JSONL, CSV, OSCAL 1.1.2
assessment-results, SSE event stream, Prometheus metrics, OCSF-shaped SIEM events. - Minimal live UI: pending approvals + decision stream.
- Standalone verifier
qed-verify(stdlib +cryptographyonly).
1.3 Explicitly out of scope (v1)¶
Identity provider functionality; payments; programmatic terms-of-service acceptance; agent self-registration (MCP CIMD); outbound request signing (RFC 9421); skill content scanning; LLM-based anomaly detection; IaC/Kubernetes manifest scanning (stays in Valqore); analytics dashboards; multi-tenant hosting; TypeScript SDK; Okta/AgentCore gateway plugins.
1.4 Design invariants (MUST)¶
- No LLM in the trust path.
qed.core.*never imports or calls a model. Explanations MAY be LLM-generated inqed.explain.*, never consulted by the engine. - No network in the core.
qed.core.*imports nohttpx,requests,aiohttp, sockets. Enforced bytests/test_core_has_no_network.py. - Determinism.
evaluate(proposal, policy_bundle, budget_state) → Verdictis a pure function. Same inputs → byte-identicalVerdict(excludingts). - Evidence is third-party verifiable. Ed25519 only; no HMAC path exists. The service refuses to start without a signing key (no dev fallback). Dev mode requires explicit
--devand stamps every recorddev: true. - Single chain. All event types go to one
EvidenceRecordchain. No side journals. - Vendor neutrality. Core knows nothing about Okta, Claude, AWS. Adapters and
qed.remotedo. - Zero bytes out by default. All
qed.remote.*features are opt-in, outbound-only, carry no customer data (hashes/signatures only), and degrade gracefully when disabled. - PII never enters the chain. Principals are stored as salted hashes; mapping lives in an erasable table (ADR-005).
2. Architecture¶
2.1 Component diagram¶
┌──────────────────────────────────────────────────────────────────────────┐
│ AGENTS / CLIENTS Claude Code · Codex · Cursor · LangGraph · kagent · CI │
└──────┬──────────────┬──────────────────┬───────────────┬─────────────────┘
│ MCP │ hook (stdin/out) │ HTTPS_PROXY │ import qed
┌──────▼──────┐ ┌─────▼─────┐ ┌──────────▼───────┐ ┌─────▼──────┐
│ adapters.mcp│ │adapters. │ │adapters.http_proxy│ │adapters.sdk│ ← normalize → ActionProposal
│ (stdio/http)│ │hooks │ │(CONNECT/host-path)│ │ @guard │ ← attach delegation token
└──────┬──────┘ └─────┬─────┘ └──────────┬───────┘ └─────┬──────┘ ← enforce verdict, report result
└──────────────┴────────┬─────────┴───────────────┘
│ HTTPS POST /v1/proposals (adapter mTLS or bearer)
┌──────────────────────────────▼───────────────────────────────────────────┐
│ qed.api (FastAPI) │
│ routers: proposals · token · approvals · evidence · policies · admin · events │
├───────────────────────────────────────────────────────────────────────────┤
│ qed.core (pure; no network, no LLM) │
│ delegation ─► policy ─► budget ─► approval ─► evidence │
│ mandate broker(credential resolve only; I/O in qed.infra) │
├───────────────────────────────────────────────────────────────────────────┤
│ qed.infra storage(sqlite|postgres) · keys(file|kms) · secrets(env|file|vault) │
│ channels(slack|web|cli) · chain_writer(leader+outbox) │
├───────────────────────────────────────────────────────────────────────────┤
│ qed.remote (optional, outbound-only) anchor · packs · intel · buildinfo │
└───────────────────────────────────────────────────────────────────────────┘
▲ OIDC/JWKS (IdP) ▲ auditors: qed-verify, OSCAL
2.2 Request lifecycle (happy path, REQUIRE_APPROVAL)¶
1 adapter builds ActionProposal, attaches Bearer <delegation_token>, POST /v1/proposals
2 api authenticates adapter (adapter_id + key or mTLS); rate-limits
3 delegation verifies JWT (JWKS or qed-issued), checks act-chain narrowing, extracts budget+mandate
4 evidence append {type: proposal} ← assurance: kernel
5 budget load BudgetState(token_jti); if exhausted → verdict BLOCK (AG-BUD-001)
6 policy evaluate(proposal, bundle, budget) → Verdict{REQUIRE_APPROVAL, rules, blast, policy_hash}
7 evidence append {type: verdict}
8 approval dedupe (ADR-006.3); create ApprovalRequest; notify channels
9 evidence append {type: approval_requested}
10 api respond 202 {verdict: REQUIRE_APPROVAL, approval_id, poll_url, expires_at}
11 adapter blocks the tool call now (returns ToolError/exit 2 with explanation) OR polls (SDK mode)
12 human decides via Slack/web/CLI → SoD check → evidence {approval_decided}
13 adapter (on retry within window or on poll) → verdict ALLOW via approval → forwards call
14 broker (enforcing adapters) injects upstream credential; agent credential stripped
15 adapter POST /v1/proposals/{id}/result → evidence {executed, execution_result} ← assurance: broker|adapter
16 budget decrement steps/tokens/money
17 events SSE fan-out to UI/SIEM; metrics updated
ALLOW path: steps 1–7, then 13–17 immediately (synchronous 200). BLOCK path: 1–7, respond 200 {verdict: BLOCK, rules, fix}, adapter denies.
2.3 Processes and deployment shapes¶
- Single process (
qed serve): API + core + chain writer + channels + UI. SQLite. Laptop / small team. - HA (Helm, ≥2 replicas, Postgres): every replica serves API and decides; exactly one holds
pg_try_advisory_lock(1734440037)(0x67617465) and drainsevidence_outboxinto the chain (ADR-004). Leader loss → lock released → another replica acquires within seconds; outbox is durable. - CI one-shot (
qed check): no server; evaluates locally with a policy bundle, writes evidence to a local file or posts to a server if configured.
3. Domain model (Pydantic v2, qed/core/schemas.py)¶
All models: model_config = ConfigDict(frozen=True, extra="forbid"). IDs are ULIDs (26 chars). Timestamps are RFC 3339 UTC with millisecond precision.
3.1 ActionProposal¶
class Principal(BaseModel):
human_hash: str | None # sha256(sub + instance_salt) hex; None for unattended
agent_chain: list[str] # ordered, outermost first: ["claude-code@laptop-ali", "sub:planner"]
mandate_id: str | None # required when human_hash is None
token_jti: str
client_fingerprint: (
str | None
) # adapter-provided; ADVISORY (spoofable) — never used for enforcement
class ActionKind(StrEnum):
mcp_tool = "mcp_tool"
http = "http"
shell = "shell"
skill = "skill"
custom = "custom"
class Action(BaseModel):
kind: ActionKind
name: str # "github.create_pull_request" | "POST /v2/sg" | "Skill:deploy-prod"
target: str # URI-ish resource id: "repo:acme/payments", "db:prod/orders", "aws:ec2:sg-123"
params_hash: str # sha256 of canonical JSON of raw params (raw never sent)
params_redacted: dict | None # masked copy (ADR-005 masks); optional
method: str | None # http only
class BlastHints(BaseModel):
writes: bool = False
deletes: bool = False
scope: Literal["single_resource", "multi_resource", "account", "global"] = "single_resource"
class CostEstimate(BaseModel):
tokens: int = 0
money_cents: int = 0
currency: str = "USD"
class Enforcement(StrEnum):
advisory = "advisory"
enforcing = "enforcing"
class Mode(StrEnum):
shadow = "shadow"
enforce = "enforce"
class Context(BaseModel):
environment: Literal["dev", "staging", "prod"]
tags: list[str] = []
adapter: str # "mcp-proxy/0.3.1"
adapter_id: str # registered adapter identity
enforcement: Enforcement # ADR-001
mode: Mode # ADR-003 (service-level, as seen by adapter)
cost_estimate: CostEstimate = CostEstimate()
blast_radius_hints: BlastHints = BlastHints()
intent: str | None # agent-declared intent, masked
summary: str # adapter-built one-liner for approvers, masked
class ActionProposal(BaseModel):
schema_version: Literal["1.0"] = "1.0"
id: str # ULID
ts: datetime
principal: Principal
action: Action
context: Context
3.2 Verdict¶
class Decision(StrEnum):
ALLOW = "ALLOW"
BLOCK = "BLOCK"
REQUIRE_APPROVAL = "REQUIRE_APPROVAL"
class RuleHit(BaseModel):
rule_id: str
verdict: Decision
severity: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]
mode: Mode
message: str
fix: str | None
compliance: list[ComplianceRef]
class Verdict(BaseModel):
proposal_id: str
decision: Decision # effective (shadow rules excluded)
would_decision: Decision # if all shadow rules were enforced (ADR-003)
hits: list[RuleHit] # ALL triggered rules, enforce and shadow
blast_radius: int # 0–100
policy_hash: str # sha256 of canonical bundle
engine_version: str
default_applied: bool # True when no rule matched (AG-DEF-001 recorded)
approval_id: str | None
via_approval: str | None # approval id when ALLOW came from an open window
3.3 Delegation token (JWT claims)¶
{
"iss": "https://qed.acme.internal", "sub": "ali@acme.com" | "svc:sre-agent",
"aud": "qed", "exp": 1760000000, "iat": 1759998200, "jti": "01J...",
"act": { "sub": "claude-code@laptop-ali", "act": { "sub": "sub:planner" } }, // RFC 8693 nested actor
"scope": "mcp:postgres:read mcp:github:* http:aws:ec2:describe*",
"https://qed.dev/budget": { "steps": 50, "tokens": 200000, "seconds": 1800, "money_cents": 0, "currency": "USD" },
"https://qed.dev/mandate": "md_01J..." , // required for svc: subjects
"https://qed.dev/env": ["dev","staging"], // allowed environments
"https://qed.dev/parent_jti": "01J..." // when exchanged from another qed token
}
3.4 Mandate (ADR-008.2)¶
mandate:
schema_version: "1.0"
id: md_01J...
issued_by_hash: <principal_hash> # the human who signed
for_agent: "svc:sre-agent" # IdP client_id or k8s SA
purpose: "Automated remediation for payment-api alerts"
allowed:
kinds: [http]
name_globs: ["GET *", "PUT /ec2/*", "POST /ec2/*"]
target_globs: ["aws:ec2:*", "aws:rds:describe*"]
environments: [prod]
budget: { steps: 20, tokens: 0, seconds: 600, money_cents: 0 } # per token
oversight:
approvers: [sre-oncall]
require_approval_when: ["blast_radius > 60"]
valid: { from: "2026-10-01T00:00:00Z", until: "2026-12-31T23:59:59Z" }
signature: { key_id: "oidc:okta:kid-abc", alg: "RS256|EdDSA", sig: "..." } # signed via qed mandate sign (OIDC-authenticated)
mandates; issuance appended as evidence mandate_issued. Policy engine treats allowed as an additional constraint (outside → BLOCK AG-IDN-006).
3.5 EvidenceRecord (ADR-004)¶
class EvidenceType(StrEnum):
genesis = "genesis"
proposal = "proposal"
verdict = "verdict"
approval_requested = "approval_requested"
approval_decided = "approval_decided"
approval_expired = "approval_expired"
executed = "executed"
execution_result = "execution_result"
budget_update = "budget_update"
budget_exhausted = "budget_exhausted"
policy_loaded = "policy_loaded"
key_rotated = "key_rotated"
mandate_issued = "mandate_issued"
mandate_revoked = "mandate_revoked"
ungated_window = "ungated_window"
correction = "correction"
checkpoint = "checkpoint"
admin_action = "admin_action"
class Assurance(StrEnum):
kernel = "kernel"
broker = "broker"
adapter = "adapter"
self_ = "self" # ADR-007
class EvidenceRecord(BaseModel):
schema_version: Literal["1.0"]
seq: int # 0 = genesis, strictly increasing
ts: datetime
type: EvidenceType
assurance: Assurance
mode: Mode | None # for proposal/verdict/executed
enforcement: Enforcement | None
dev: bool = False
payload_hash: str # sha256(JCS(payload))
payload: dict # redacted; may be purged after retention (hash remains)
prev_hash: str
hash: str # see §10.2
key_id: str
sig: str # base64url Ed25519 over hash bytes
3.6 Other entities¶
ApprovalRequest, ApprovalDecision, BudgetState, PolicyBundle, SigningKey, Upstream, AdapterIdentity, Checkpoint, PrincipalMap — fields in §15 DDL.
4. Policy language (ADR-002)¶
4.1 File layout¶
policies/ directory; *.yaml files; each a list of rules. A bundle = all files, sorted by rule_id, canonicalized. Baseline pack ships in qed/core/policy/packs/agent-baseline/.
4.2 Rule schema¶
- rule_id: AG-BLR-003 # ^[A-Z]{2,6}(-[A-Z0-9]{2,8})+$ ; unique in bundle
name: Destructive action in production
severity: HIGH # LOW|MEDIUM|HIGH|CRITICAL — reporting only, never affects decision
mode: enforce # enforce|shadow (default: pack/service setting)
applies_to: # ALL listed constraints must hold (AND); omitted = any
kinds: [mcp_tool, http, shell]
name_globs: ["*"]
target_globs: ["db:prod/*", "aws:*"]
environments: [prod]
adapters: ["mcp-proxy/*"]
when: # predicate tree; omitted = always (given applies_to)
any:
- { field: context.blast_radius_hints.deletes, equals: true }
- { field: action.params_redacted.sql, regex: "(?i)^\\s*(drop|truncate|alter)\\b" }
verdict: REQUIRE_APPROVAL # BLOCK|REQUIRE_APPROVAL (no ALLOW rules)
approval: # only for REQUIRE_APPROVAL
approvers: [sre-oncall, dba] # groups (from IdP claims) or principals
timeout: 10m
window_max: 30m # cap for "approve for N minutes"; default from service config
escalate_to: [platform-leads] # optional
escalate_after: 5m
message: "Destructive change to a production datastore."
fix: "Run against staging first or request approval with an explicit intent."
compliance:
- { framework: eu_ai_act, control: "Art. 14" }
- { framework: nist_ai_rmf, control: "GOVERN-1.5" }
- { framework: owasp_agentic, control: "ASI04" }
references: ["https://..."]
4.3 Override and disable¶
- rule_id: AG-DAT-007
override:
verdict: REQUIRE_APPROVAL # may only move BLOCK→REQUIRE_APPROVAL (soften); never →ALLOW
applies_to: { environments: [staging] } # may only NARROW
approval: { approvers: [sec-team] }
- rule_id: AG-SKL-002
disabled: true
reason: "We do not use skills; re-enable when Claude Code rollout starts." # REQUIRED
disabled_by: "platform-team"
disabled without reason; duplicate rule_id; invalid regex; unknown field path.
4.4 Predicates¶
Leaf: { field: <dotted path into ActionProposal | derived>, <op>: <value> }
Ops: equals, not_equals, in, not_in, glob, regex, gt, gte, lt, lte, exists (bool), contains (string/array).
Composite: any: [...], all: [...], not: {...}.
Derived fields available: derived.blast_radius (int, computed before rules), derived.budget.steps_remaining, derived.budget.tokens_remaining, derived.budget.seconds_remaining, derived.budget.money_remaining_cents, derived.mandate.present (bool), derived.mandate.within (bool), derived.chain_depth (int), derived.history.calls_24h, derived.history.blocks_24h.
Field access on params_redacted only; raw params never reach the engine.
A predicate that cannot be evaluated — in practice a regex that spends its timeout — MUST NOT count as a match and MUST NOT fail the proposal: its rule is skipped and the verdict MUST carry a synthetic AG-PRD-001 hit, mode: shadow, naming the rule and the reason (ADR-0016).
4.5 Precedence and defaults (MUST)¶
- Compute
applies_tofilter → candidate rules. - Evaluate
whenfor each candidate → hits. Awhenthat cannot be evaluated skips its rule and adds the syntheticAG-PRD-001record (ADR-0016); it does not become a hit and does not fail the decision. - Effective decision = max over enforce-mode hits by order
BLOCK > REQUIRE_APPROVAL; if no enforce hits →default_verdict(config; defaultALLOW) anddefault_applied = true, synthetic hitAG-DEF-001. would_decision= same over all hits (enforce + shadow).- Mandate constraint check (
derived.mandate.within == falsefor unattended) → syntheticAG-IDN-006BLOCK, enforce mode, regardless of rules. - Budget exhausted → synthetic
AG-BUD-001BLOCK before step 1 (short-circuit; still record). - Result is independent of rule file order (test).
4.6 Baseline pack (agent-baseline, ~50 rules) — categories and representative IDs¶
AG-IDN-*identity/delegation: 001 missing token, 002 expired, 003 chain not narrowing, 004 scope mismatch, 005 unknown adapter, 006 outside mandate, 007 unattended without mandate.AG-BUD-*budgets: 001 exhausted (BLOCK), 002 ≥80% consumed (shadow→warn via REQUIRE_APPROVAL off by default), 003 money budget hit.AG-BLR-*blast radius: 001 account/global scope in prod (BLOCK), 002 multi-resource delete (REQUIRE_APPROVAL), 003 destructive in prod (REQUIRE_APPROVAL), 004 IAM/secret/policy mutation (REQUIRE_APPROVAL), 005 scale > threshold.AG-SOD-*separation of duties: 001 approver == proposer (enforced in approval service, surfaced as rule), 002 agent attempted self-approval.AG-DAT-*data/egress: 001 secret pattern in params, 002 PII pattern in params, 003 egress to non-allowlisted domain, 007 open ingress 0.0.0.0/0 (BLOCK).AG-SKL-*skills: 001 unknown skill source, 002 skill requests tools outside token scope, 003 skill without allowed-tools manifest.AG-DEF-001no rule matched (synthetic).AG-PRD-001a rule's predicate could not be evaluated and the rule was skipped (synthetic, shadow mode, records only). Each rule carriescompliancemappings imported from Valqore's AIG rules (mapping data only).
5. Policy engine algorithm (qed/core/policy/engine.py)¶
def evaluate(
p: ActionProposal, bundle: PolicyBundle, budget: BudgetState, ctx: DerivedContext
) -> Verdict:
hits: list[RuleHit] = []
if budget.exhausted():
return _synthetic(p, "AG-BUD-001", BLOCK, bundle, ctx)
derived = compute_derived(p, budget, ctx) # blast radius, mandate.within, history
if p.principal.human_hash is None and not derived.mandate.present:
return _synthetic(p, "AG-IDN-007", BLOCK, bundle, ctx)
if derived.mandate.present and not derived.mandate.within:
return _synthetic(p, "AG-IDN-006", BLOCK, bundle, ctx)
for rule in bundle.active_rules(): # sorted by rule_id; disabled excluded
if not applies(rule.applies_to, p):
continue
if rule.when is None or predicate(rule.when, p, derived):
hits.append(RuleHit.from_rule(rule))
enforce_hits = [h for h in hits if h.mode == enforce]
decision = max_decision(enforce_hits) if enforce_hits else bundle.default_verdict
would = max_decision(hits) if hits else bundle.default_verdict
if not enforce_hits:
hits.append(_default_hit())
return Verdict(
decision=decision,
would_decision=would,
hits=sorted(hits, key=lambda h: h.rule_id),
blast_radius=derived.blast_radius,
policy_hash=bundle.hash,
engine_version=ENGINE_VERSION,
default_applied=not enforce_hits,
proposal_id=p.id,
)
max_decision: BLOCK > REQUIRE_APPROVAL. Blast radius: deterministic table (kind, scope, writes/deletes, environment, name patterns) → 0–100; documented in docs/blast-radius.md; ported from Valqore governance/blast_radius.py with the K8s-specific parts removed.
compute_derived.history reads a snapshot passed in ctx rather than querying anything — the engine itself performs no I/O.
check_proposal collects it (qed/core/history.py), since 2026-10-15. For twelve weeks before that nothing did — this sentence used to claim the API layer collected it, which was never true and was invisible because zero is a plausible number — so AG-BUD-007 (calls_24h >= 500) and AG-IDN-010 (blocks_24h >= 5) were shipped, counted in the pack's rule total and in its compliance mappings, and could not fire.
Both numbers come from the evidence chain and nothing else, because that is the only source an auditor also has: calls_24h is proposal records for this principal in the window, blocks_24h is verdict records for this principal in the window whose decision was BLOCK.
The window is aligned, not rolling, and that single choice is what makes the collection both affordable and replayable. It covers [end - 24h, end) with end floored to history.refresh_seconds (§16, default 60), so:
- every decision inside one bucket shares a value — the query runs once per principal per bucket instead of once per decision, which is what keeps a store round-trip off the decision path (§21);
- the window is a pure function of the decision's own timestamp, so
qed replayrecomputes the identical numbers from the chain (§14). A cadence implemented as a TTL cache would have been easier and would have made every replay of a history-dependent rule inconclusive forever, because nothing records which stale value a decision happened to see.
The trade is explicit: a burst inside one bucket is counted in the next bucket. Both shipped thresholds are daily volumes, so a minute of staleness cannot change an answer a human would call correct. refresh_seconds: 0 turns alignment off — exact counters, one query per decision, still replayable.
Three things are not counted, each deliberately:
- an unattended agent. The counters key on
principal_hash, which is NULL when there is no human principal, so such an action contributes to nothing and sees zeros. Grouping every unattended agent under one NULL identity would make "this principal has been blocked repeatedly" mean "some agent somewhere has been". - anything before migration 0006.
decisionis denormalised ontoevidenceso blocks can be counted from indexed columns; it is NULL on older rows, so the block counter starts at the migration rather than claiming a history it cannot see. - nothing at all, when no active rule reads
derived.history.*. A bundle that cannot look at the counters is never queried for them.
A query that fails is logged and treated as zeros. Both rules that read these counters are shadow rules about a usage pattern; refusing an action because a COUNT failed would be the worst trade available.
6. Delegation, tokens, mandates (qed/core/delegation, qed/api/routers/token.py)¶
6.1 Verifying an incoming token¶
- Parse JWT header;
kid→ key: qed token key (qed-issued) or IdP JWKS (cached byqed.infra.jwks, offline cache for air-gap). - Validate
iss∈ configured issuers,aud == "qed",exp,nbf,iatskew ≤ 60 s. - Flatten
actchain →agent_chain. Depth ≤max_chain_depth(default 4). - If
parent_jtipresent: load parent; assertscope ⊆ parent.scope,budget ≤ parent.budget(each field),exp ≤ parent.exp,env ⊆ parent.env. Violation →AG-IDN-003. substarts withsvc:→ unattended →mandateclaim REQUIRED, mandate loaded andvalid.until > now,for_agent == sub, not revoked.- Produce
Principal(hash the humansubwith instance salt; map table upsert).
6.2 Token exchange endpoint POST /v1/token (RFC 8693)¶
Request (form or JSON): grant_type=urn:ietf:params:oauth:grant-type:token-exchange, subject_token (IdP access/ID token or a qed token), subject_token_type, actor_token (optional; agent identity assertion), requested_token_type=urn:ietf:params:oauth:token-type:jwt, scope, resource=qed, extras: agent_id, budget (requested; capped), mandate_id (unattended).
Logic: verify subject; compute allowed scope = scopes_by_group(IdP groups) ∩ requested; budget = min(requested, policy cap for group/mandate); lifetime = min(requested or token_ttl_default, 60m, parent.exp, mandate.valid.until); sign with token key; evidence budget_update{type: token_issued}.
Response: {access_token, issued_token_type, token_type: "Bearer", expires_in, scope}.
6.3 Attended login — qed login (RFC 8628 device flow)¶
qed login → IdP device authorization → user approves in browser → refresh token stored in OS keychain (keyring lib; file fallback with 0600 and a warning). qed token uses refresh → IdP access token → §6.2 exchange. Adapters call qed token transparently when cached token has < 5 min left.
6.4 Unattended — client credentials / K8s SA¶
Agent obtains IdP client-credentials token (or projected SA token with aud=qed) → §6.2 with mandate_id. No mandate → 403 mandate_required.
6.5 Mandate lifecycle (qed mandate sign|list|revoke)¶
sign: operator authenticates via device flow; CLI builds the mandate YAML; qed verifies the signer is in mandate_signers group; stores; appends mandate_issued. revoke: appends mandate_revoked; tokens referencing it fail at §6.1 step 5 on next use (and budget_exhausted is not needed — rejection happens at verification).
7. Credential broker (ADR-001) (qed/core/broker, I/O in qed/infra/secrets)¶
7.1 Configuration upstreams.yaml¶
upstreams:
- id: postgres-prod
kind: mcp # mcp | http
match: { mcp_server: "postgres", environments: [prod] } # how proposals map to this upstream
credential:
source: vault # env | file | vault
ref: "secret/data/qed/postgres-prod#DATABASE_URL"
inject: { type: env, name: DATABASE_URL } # for stdio MCP child process
enforcement: enforcing
- id: aws-prod
kind: http
match: { host_globs: ["*.amazonaws.com"], environments: [prod] }
credential:
source: vault
ref: "aws/creds/qed-prod" # dynamic STS creds, TTL 15m
inject: { type: sigv4 } # sign outgoing request; strip agent auth
enforcement: enforcing
- id: github
kind: mcp
match: { mcp_server: "github" }
credential: { source: env, ref: GITHUB_TOKEN, inject: { type: header, name: Authorization, format: "Bearer {value}" } }
7.2 Behavior (MUST)¶
- Enforcing adapters strip any agent-supplied
Authorization/cookies before forwarding. - Credentials resolved per request, cached in memory ≤ TTL, never logged, never in evidence (only
upstream_id). - If an upstream is not configured → adapter is
advisoryfor that target and says so incontext.enforcement. qed doctorprints posture: enforcing vs advisory per upstream.- Vault: AppRole or K8s auth; token renewal handled in
qed.infra.secrets.vault.
8. Budget ledger (qed/core/budget, storage in qed/infra/storage)¶
- Key:
token_jti. Columns:steps_used, tokens_used, seconds_first_seen, money_used_cents, updated_at. - Atomic decrement: SQLite
BEGIN IMMEDIATE; PostgresUPDATE … SET steps_used = steps_used + 1 … WHERE token_jti=$1 RETURNING *. secondsbudget =now - first_seen ≤ budget.seconds(checked at verification).- Tokens/money from
context.cost_estimate(adapter-provided; missing → 0 andAG-BUD-004 unknown_costshadow hit). - Exhaustion →
budget_exhaustedevidence + webhookbudget.exhausted. - Optional per-principal daily aggregate (
principal_hash, date) for policies usingderived.history.
9. Approval service (ADR-006) (qed/core/approval, channels in qed/infra/channels)¶
9.1 State machine¶
pending → approved | denied | expired | superseded. escalated is a flag, not a state.
9.2 Creation¶
Dedupe key K = sha256(principal_hash | action.name | action.target | params_hash). If a pending request with K exists and age < dedupe_window (10 min) → link (proposal.linked_approval) and return it. If a denied with K exists and age < 10 min → BLOCK denied_recently, no new request.
Request payload (what approvers see): summary, intent, principal display (from map table), agent chain, environment, hits (ids + messages), blast_radius, history (24h calls/blocks for this principal+target), params_redacted (collapsed), expires_at, allowed window_max.
9.3 Decision¶
Inputs: approval_id, decision ∈ {approve_once, approve_window, deny}, window_minutes (default = service window_default 15 m; must be ≤ rule window_max if set ≤ service window_max 60 m), reason (optional; required for deny if config says so), approver identity from channel auth (Slack user → mapped principal via IdP email; web/CLI → OIDC).
SoD (MUST): approver_hash != principal.human_hash; approver ∈ rule approvers (group membership from IdP claims, refreshed per decision); approver is human (not svc:). Violations recorded AG-SOD-001/002 and rejected.
Window: creates approval_window(K, until); subsequent proposals matching K within window → ALLOW with via_approval. Windows are not extendable; new request after expiry.
9.4 Timeout/escalation¶
Background ticker (leader-only in HA) expires requests → approval_expired evidence; escalation at escalate_after → notify second group, escalated=true. No reachable approvers (group empty / channel failure) → approval_unreachable payload flag.
9.5 Channels¶
- Slack: Block Kit message with buttons; interactivity endpoint verifies
X-Slack-Signature; user id → email viausers.info→ principal. Updates message on decision. - Web:
/ui/approvals(SSO), buttons callPOST /v1/approvals/{id}. - CLI:
qed approve <id> [--window 15m] [--reason]using the operator's device-flow identity. - Webhook: generic POST on
approval_requested|decided|expiredfor Teams/PagerDuty (v1 generic; native v1.x).
10. Evidence ledger (ADR-004/005/007) (qed/core/evidence, qed/infra/chain_writer)¶
10.1 Canonicalization¶
JCS (RFC 8785) for all payload hashing. payload_hash = sha256(JCS(payload)).
10.2 Hash and signature¶
H = SHA256( schema_version || 0x1f || str(seq) || 0x1f || ts_rfc3339_ms || 0x1f || type || 0x1f || payload_hash || 0x1f || prev_hash )
hash = hex(H)
sig = base64url( Ed25519.sign(signing_key[key_id], H) )
genesis.prev_hash = hex(SHA256("gate-genesis:" + instance_id))
"evidence-chain/1:genesis:" + instance_id, and principal_hash likewise uses
"evidence-chain/1:principal:". The product name is deliberately absent from
both: it was gate when this line was written and it is QED now, and a hash
domain that carries a brand makes a rename either break every existing chain or
leave a dead name in the one file an auditor reads most closely. The literal
above is left as written so that ADR-0006 still quotes something real.
assurance, mode, enforcement, dev, key_id are inside payload as well (so they are covered by payload_hash) and mirrored as columns for querying.
10.3 Writer model¶
- SQLite: single process, direct append in one transaction (
seq = last+1). - Postgres: deciders insert into
evidence_outbox(id, created_at, record_json); leader (advisory lock) drains increated_at, idorder, assignsseq, computes hash/sig, inserts intoevidence, deletes outbox row — one transaction per batch (≤100). Decision latency is independent of chain latency. - API responses return
evidence_ref = outbox idimmediately;GET /v1/evidence/by-ref/{id}resolves toseqonce committed.
10.4 Checkpoints¶
Every 1,000 records or 3,600 s (first reached): compute Merkle root over hash of records since last checkpoint (binary tree, duplicate last leaf when odd); append checkpoint{from_seq, to_seq, merkle_root}; signed like any record; publish via configured publishers (s3-object-lock, gcs-retention, git-tag, file, and qed-anchor — the qed.remote.anchor client, configured as a publisher entry rather than under remote: so there is one source of truth for which anchors are live, ADR-0013). qed-anchor sends six values, not the record (log_id, seq, hash, merkle_root, key_id, sig): a checkpoint payload carries the previous checkpoint's receipts, whose target names the operator's buckets and git remotes, and invariant §1.7 says these features carry hashes and signatures only. log_id is required with no default. Publication results (published_to[], receipts) are stored in checkpoints table (not in the chain, to avoid recursion); the next checkpoint's payload includes the previous checkpoint's receipts.
10.5 Keys¶
SigningKey(key_id, alg="Ed25519", public_pem, created_at, retired_at, source: file|kms). Rotation: qed keys rotate → new key active → key_rotated{old_key_id, new_key_id, new_public_pem} record signed by the old key (continuity) → subsequent records by new key. qed keys export-public for auditors. KMS mode (AWS KMS Ed25519 unsupported → use ECDSA_P256 variant; alg field accommodates; default file/Vault-transit Ed25519).
10.6 Retention and PII (ADR-005)¶
payload may be purged after retention (per type); payload_hash/hash/sig/prev_hash never purged → chain verifies forever. Principals appear only as principal_hash. Masking at write: regex set for email, phone, IBAN, credit card, JWT, AWS keys, GitHub tokens, private key blocks, password= patterns; test fixtures in tests/masking/.
10.7 Verification (packages/qed-verify)¶
Inputs: records (JSONL or DB export), public keys (PEM by key_id), optional checkpoint receipts.
Checks: seq contiguous from 0; prev_hash linkage; hash recomputation (needs payload_hash, not payload — works on purged records); signature with key_id; key_rotated continuity; checkpoint Merkle roots; optional receipt match (S3 object metadata / transparency log inclusion proof).
Modes: --full, --checkpoints, --range from:to. Output: human summary + JSON report + exit code. Zero dependencies beyond cryptography.
10.8 Exports¶
- JSONL (raw records), CSV (flattened), OSCAL 1.1.2
assessment-results: oneresultper export; each evidence record →observation(type byassurance), eachRuleHit→findingwithrelated-observations,target.target-id = compliance.controlusing canonical framework ids;back-matterincludes public keys and checkpoint receipts. Validate in CI with NISToscal-cli(or JSON Schema) — MUST pass. docs/assurance-classes.mdfor auditors explainskernel/broker/adapter/self.
11. Compliance mapping (qed/compliance)¶
frameworks.yaml: canonical ids and aliases (soc2: ["SOC2","SOC 2"],iso42001: ["ISO/IEC 42001","ISO 42001"],eu_ai_act,nist_ai_rmf,dora,owasp_agentic,owasp_mcp,owasp_llm,nist_csf,iso27001). Loader normalizes; unknown framework → lint warning.- Packs v1 — authored here, not imported (ADR-0011:
valqore-engineis proprietary and this repo is Apache-2.0; titles written against the standards' own published material):eu_ai_act,nist_ai_rmf,iso_42001,dora,owasp_agentic,owasp_mcp,owasp_llm,soc2, plusnist_csfandiso_27001. Each pack: controls with ids/labels, the standard'ssourceURL, and the edition where identifiers depend on it; rule→control links come from rulecomplianceblocks. A label is the catalogue's description, never a quotation. - Each pack declares
catalogue: complete|partial, and coverage refuses a percentage for a partial one (ADR-0011) —n of nover a curated subset reads as complete and is not. A partial pack must state itsscope. An unmapped framework still ships a catalogue so the zero is visible. A control no catalogue defines is a lint error (an unknown framework stays a warning): it would reach OSCAL looking like a real mapping. - Honesty flags: pack metadata
depth: article|category|pack— EU AI Actarticle, NIST AI RMFcategory, DORAarticlesince week 9 (Chapter II mapped to Articles 8, 9 and 10, titles checked against EUR-Lex). ISO 42001 stayspack: the deepening it needs is Annex A, and ISO/IEC 42001 is sold rather than published, so those titles are not available from an authoritative source — ADR-0011 forbids writing them from memory. Unblocked by a licensed copy or by a design partner publishing a signed pack. Exposed in OSCAL as a property so auditors see mapping depth. - Signed pack updates (
qed.remote.packs, ADR-0012): a bundle is one self-contained signed JSON envelope (*.qedpack.json), not a detached.sig— two files can be separated and the failure is quiet. The signature is overqed/core/jcs.py's canonical form of the envelope minussignature, sokey_id,issuerandissued_atare all covered. The pack key is not the evidence key (the evidence key says what this instance did; a pack key says what a publisher published), there is no default key, and the bundle's ownkey_idis never accepted as the answer to which key to use —qed packs import --public-key PEM.qed packs verifyshows a bundle and installs nothing. Every import appends anadmin_action(bundle hash, issuer,issued_at, key id, frameworks, signed or not) and re-issuespolicy_loaded, because importing a catalogue does not change the policy hash andpolicy_loadedis the record every verdict points at. Unsigned packs load behind--unsignedwithunsigned: trueinpolicy_loadedand the frameworks named; provenance comes from the manifest the import wrote, never from the catalogue files. Refused: a bundle that would leave an installed rule citing a control it does not define (checked before anything is written — an import must not be able to stop the kernel deciding), and a bundle issued before the installed one. Theurlfeed is not built; the air-gap import reaches no network, so no key underremote:is read yet.
12. Adapters (qed/adapters/*)¶
Common adapter library qed/adapters/common.py: build proposal, attach token (auto-refresh), call API with timeout (default 2 s decision, configurable), fail-mode handling, local pending queue for fail_open (~/.qed/pending.jsonl, replayed as ungated_window/late records when kernel returns), result reporting.
12.1 MCP proxy (qed mcp -- <cmd> / qed mcp --upstream-url …)¶
- Implements MCP server toward the client and MCP client toward upstream (both stdio and Streamable HTTP/SSE) using the official
mcpPython SDK. initialize: passthrough; injectqedintoserverInfo.namesuffix? No — keep upstream identity, add capability note ininstructions.tools/list: fetch upstream list; filter by token scope (mcp:<server>:<tool>globs); cache per session.tools/call: buildActionProposal{kind: mcp_tool, name: "<server>.<tool>", target: derive_target(server, tool, args), params_hash, params_redacted, summary, intent from _meta.intent}; POST; on ALLOW forward with broker credential (if configured) else passthrough; on BLOCK return MCPToolErrorwithmessage = "[qed BLOCK] <rule ids>: <message>. Fix: <fix>"; on REQUIRE_APPROVAL returnToolError"[qed APPROVAL PENDING] id=<approval_id> expires=<ts>. Retry after approval."(MCP has no async tool completion in v1; SDK mode can poll).- Target derivation: configurable per server in
upstreams.yaml(target_template: "db:{env}/{args.table}"); default"<server>:<tool>". - Resources/prompts: passthrough (read-only) but logged as
proposal{kind: mcp_tool, name: "<server>.resources/read"}in shadow (never blocked in v1). - Fail mode default
closed. Mode default from service.
12.2 Coding-agent hooks (qed install claude-code|codex)¶
- Claude Code: writes
PreToolUsehook into project/user settings callingqed hook claude-code; reads JSON on stdin (tool_name,tool_input,session_id,cwd); mapsBash → shell,Edit/Write → custom(name=fs.write),mcp__<server>__<tool> → mcp_tool,Skill → skill; summary from tool input; exit 0 allow, exit 2 block with reason on stderr (Claude Code shows it to the model);REQUIRE_APPROVAL→ exit 2 with pending message (hook cannot wait;--waitoption polls up to timeout for interactive use). - Codex: equivalent hook/wrapper per Codex docs (implement when API confirmed; wrapper fallback that intercepts
exec). - Always
enforcement: advisory. Fail defaultopenindev,closedinprodprofile. qed installalso prints how to lock hooks via managed settings (advisory hardening).
12.3 HTTP forward proxy (qed proxy)¶
- Listens for
CONNECT(host:port) and plain HTTP. v1 defaultmode: host_path: for CONNECT, decision onhost(+ SNI) only, tunnel passthrough; for plain HTTP, full method/path. Optionalmode: mitmwith customer CA: decrypts, decides on method/path/body-shape, injects broker credentials (sigv4/header), re-encrypts. - Proposal:
kind: http, name: "<METHOD> <path-or-host>", target: map_host_to_target(host)(e.g.,*.amazonaws.com→aws:<service>:…usingupstreams.match). - Fail default
closed.
12.4 Python SDK (qed.sdk)¶
from qed.sdk import QED, guard
g = QED.from_env() # QED_URL, token via qed token or QED_TOKEN
@guard(g, kind="custom", target="crm:accounts", summary=lambda a, kw: f"update account {a[0]}")
def update_account(account_id, fields): ...
# or explicit
v = g.propose(
kind="http",
name="POST /payments",
target="stripe:charges",
params=payload,
intent="refund order 123",
)
if v.decision == "REQUIRE_APPROVAL":
v = g.wait(v.approval_id, timeout=600)
guard raises QedBlocked(verdict) / QedApprovalPending(verdict); reports results automatically (assurance: adapter).
12.5 CI command (qed check proposal.json [--policies ./policies] [--server URL])¶
Evaluates (locally or via server), prints verdict, exit 0 ALLOW / 2 BLOCK / 3 REQUIRE_APPROVAL. Writes evidence to server if configured, else ./qed-evidence.jsonl (self-signed with a CI key, assurance: self).
12.6 Adapter identity¶
Each adapter registers (qed adapters add mcp-proxy-ci) → adapter_id + secret (or mTLS cert). API rejects unknown adapters (AG-IDN-005). Rotation via CLI. Built 2026-10-15.
The secret is generated by add, printed once, and never written anywhere: qed.yaml carries secret_env, the name of the environment variable, so the config file stays safe to commit and the secret stays out of the chain. There is no command that shows it again.
Registering an adapter grants an identity the right to submit proposals, so adapters_add and adapters_rotate are self-governed admin operations (§17) and leave an admin_action record naming the adapter and the variable — not the secret.
Rotation has an overlap, and this is the part the one-line specification hid. A shared secret cannot be changed on the instance and on every adapter at the same instant. Between the two, requests signed with the old secret are refused as AG-IDN-005 — an unknown adapter, which under fail: closed is a blocked action, so a naive rotation is an outage. So rotate moves the secret to a new variable and records the old one as previous_secret_env; both are accepted until qed adapters rotate <id> --finish removes it. The same shape as key rotation (ADR-0010): the old credential stays usable for exactly as long as something may still present it.
13. HTTP API (qed/api)¶
Base /v1. Auth: adapters → X-QED-Adapter-Id + HMAC-signed request (or mTLS); humans/admins → OIDC bearer. All responses JSON; errors RFC 9457 problem+json.
| Method & path | Purpose | Notes |
|---|---|---|
POST /proposals |
Submit ActionProposal | 200 {verdict, evidence_ref, approval?}; 202 when REQUIRE_APPROVAL |
GET /proposals/{id} |
Fetch proposal + verdict | |
POST /proposals/{id}/result |
Report execution {status, http_status?, error?, cost_actual?} |
assurance from adapter enforcement |
POST /token |
RFC 8693 exchange | §6.2 |
GET /approvals?status=pending |
List | approver-scoped |
GET /approvals/{id} |
Detail for approver UI | |
POST /approvals/{id} |
{decision, window_minutes?, reason?} |
SoD enforced |
GET /evidence?from&to&type&format=jsonl|csv|oscal&packs= |
Export | streaming |
GET /evidence/by-ref/{outbox_id} |
Resolve to seq | |
GET /evidence/verify?from&to |
Server-side verify (convenience; auditors use qed-verify) | |
GET /policies · POST /policies:reload · POST /policies:lint |
Bundle mgmt | reload requires admin approval (ADR-007.2) |
GET /mandates · POST /mandates · POST /mandates/{id}:revoke |
Mandates | signer group |
GET /upstreams |
Posture (no secrets) | |
GET /events |
SSE stream of evidence (redacted) | UI, SIEM |
GET /metrics |
Prometheus | |
GET /healthz · GET /readyz |
Health | readyz false without signing key |
POST /admin/keys:rotate · GET /admin/keys/public |
Keys | admin approval |
Rate limits: per adapter 100 proposals/s default; per principal 20/s. 429 with Retry-After. Built in week 10, with three things the line above does not say. The bucket holds 3 seconds' worth (BURST_SECONDS), because an agent making thirty tool calls while deciding what to do next is ordinary and a one-second bucket at 20/s would refuse it -- on fail: closed a 429 is a blocked action, so a limit on a gate is a kill switch under load. The sustained rate is unchanged. The limit is per process, reported as such in /v1/status: four replicas admit four times the number. And it lives under api.rate_limit, not adapters.rate_limit, because adapters is a list of credentials in this build and a mapping there would not parse. A refusal writes nothing to the chain.
14. CLI (qed, Typer) — 22 top-level commands (vs. Valqore's 208); no new top-level command without a spec update¶
demo · serve [--ui] [--dev] · mcp -- <cmd> | --upstream-url · proxy · hook <agent> · install <agent> · login · token · check · approve · approvals · status · checkpoint · mandate sign|list|revoke · policy lint|test|explain|normalize-frameworks · keys init|rotate|export-public · adapters add|list|rotate · replay · export · verify (thin wrapper over qed-verify) · doctor [--offline] · packs import|list|verify.
demo decides five proposals against a throwaway instance and verifies the chain it wrote: no configuration, no keys, no network, every record marked dev, and exit 0 although it contains a BLOCK (§12.5 belongs to check, and a demo's exit code is whether the demo ran).
adapters add|list|rotate was built on 2026-10-15. add writes the entry into qed.yaml and prints a generated secret once; list reports whether each registered adapter's secret_env is actually set and long enough, which previously required starting the server and reading /v1/status; rotate issues a new secret while continuing to accept the old one until --finish, because a shared secret cannot change on two machines at the same instant and a cutover rotation refuses live traffic as AG-IDN-005.
replay <proposal_id> was built on 2026-10-15, and the inventory above is now the whole of §14 -- nothing specified here is unbuilt.
--version is the one global option, added 2026-10-17: it prints VERSION (§25.6) together with whether this is the single-file binary or a wheel, and the interpreter it is running on. It is a flag and not a command, so the inventory above is unchanged. It is eager -- it answers before any state directory is resolved, because the person most likely to type it has just downloaded a binary and has no qed.yaml. doctor reports the same number as its first line: VERSION is ENGINE_VERSION, stamped into every verdict record, so "which engine decided this?" and "which engine am I holding?" must have the same answer available.
replay decides a recorded proposal again and reports whether the record follows from the policy it says it was decided under. This is the question verify cannot answer: every check the verifier makes is self-referential, so a chain an operator rewrote and re-signed passes it completely (THREAT_MODEL.md, chain-rewrite row). Deciding the proposal again is what notices, and tests/cli/test_replay.py proves it on a re-signed chain that qed verify returns 0 for.
What the chain can and cannot give it is the whole design:
- Exact. The proposal and the verdict are recorded in full, and
check_proposalstamps the proposal record, the verdict record andctx.nowfrom one value -- so the engine's clock is the record's own timestamp, not an approximation. - Not in evidence at all.
policy_loadedcarries a bundle's hash, not its rules, so the policy cannot be reconstructed from the chain. A hash mismatch makesreplayrefuse rather than answer: re-deciding under today's rules is a different question, and presenting its answer as a replay would be the most misleading thing the command could do.--policypoints it at the archived bundle. - Assumed, and named. The budget ledger state (the ledger has moved since) and the signed mandate (
mandate_issuedkeeps an id and a window, not the document). Every difference is classified against the rules that readderived.budget.*/derived.mandate.*, so a difference an assumption explains is reported as inconclusive and a difference in a rule reading only the proposal is reported as a finding.blast_radiusis never excused -- it is computed from the proposal, which is recorded.
Exit 0 reproduced, 2 differs with nothing to account for it, 1 the question could not be answered (no such proposal, a chain that does not verify, a policy whose hash does not match). Three answers, three codes: folding "differs" into 1 would make a forged record indistinguishable from a missing file.
The 24-hour history counters are recovered, not assumed, which is what §5's aligned window buys: the window is a pure function of the decision's timestamp, so replay counts the same two numbers back out of the chain. They fall back to assumed in two cases, and replay says so when they do — a verdict inside the window whose payload retention has stripped, which makes blocks_24h a lower bound; and history.refresh_seconds having changed since the decision, which moves the boundary, so --refresh-seconds exists to say what it was.
tests/cli/test_command_set.py holds this list to the CLI in both directions: a command the CLI gained and this line did not is a failure, and so is a name here that no longer exists. The rule above said "no new top-level command without a spec update" for twelve weeks and nothing checked it, which is how approvals, status and checkpoint came to be built and unlisted.
15. Storage schema (SQL; SQLite and Postgres dialects via qed/infra/storage/migrations/NNNN_*.sql)¶
CREATE TABLE evidence (
seq BIGINT PRIMARY KEY, ts TEXT NOT NULL, type TEXT NOT NULL, assurance TEXT NOT NULL,
mode TEXT, enforcement TEXT, dev BOOLEAN NOT NULL DEFAULT FALSE, schema_version TEXT NOT NULL,
payload_hash TEXT NOT NULL, payload TEXT, -- JSON; NULL after purge
prev_hash TEXT NOT NULL, hash TEXT NOT NULL UNIQUE, key_id TEXT NOT NULL, sig TEXT NOT NULL,
proposal_id TEXT, approval_id TEXT, principal_hash TEXT -- denormalized for queries
);
CREATE INDEX ix_evidence_ts ON evidence(ts); CREATE INDEX ix_evidence_proposal ON evidence(proposal_id);
CREATE TABLE evidence_outbox (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, record_json TEXT NOT NULL);
CREATE TABLE checkpoints (seq_to BIGINT PRIMARY KEY, seq_from BIGINT, merkle_root TEXT, evidence_seq BIGINT, published_to TEXT /*JSON*/);
CREATE TABLE signing_keys (key_id TEXT PRIMARY KEY, alg TEXT, public_pem TEXT, source TEXT, created_at TEXT, retired_at TEXT);
CREATE TABLE principals (principal_hash TEXT PRIMARY KEY, sub TEXT, display_name TEXT, idp_iss TEXT, created_at TEXT, erased_at TEXT);
CREATE TABLE tokens (jti TEXT PRIMARY KEY, principal_hash TEXT, agent_chain TEXT, scope TEXT, budget TEXT, mandate_id TEXT, parent_jti TEXT, exp TEXT, issued_at TEXT, revoked_at TEXT);
CREATE TABLE budgets (token_jti TEXT PRIMARY KEY, steps_used INT DEFAULT 0, tokens_used BIGINT DEFAULT 0, money_used_cents BIGINT DEFAULT 0, first_seen TEXT, updated_at TEXT);
CREATE TABLE proposals (id TEXT PRIMARY KEY, ts TEXT, token_jti TEXT, principal_hash TEXT, kind TEXT, name TEXT, target TEXT, params_hash TEXT, environment TEXT, adapter_id TEXT, enforcement TEXT, mode TEXT, proposal_json TEXT, verdict_json TEXT, linked_approval TEXT);
CREATE TABLE approvals (id TEXT PRIMARY KEY, proposal_id TEXT, dedupe_key TEXT, status TEXT, approvers TEXT, created_at TEXT, expires_at TEXT, escalate_at TEXT, escalated BOOLEAN DEFAULT FALSE, decided_at TEXT, approver_hash TEXT, decision TEXT, window_until TEXT, reason TEXT, request_json TEXT);
CREATE INDEX ix_approvals_dedupe ON approvals(dedupe_key, status);
CREATE TABLE approval_windows (dedupe_key TEXT, approval_id TEXT, until TEXT, PRIMARY KEY(dedupe_key, approval_id));
CREATE TABLE mandates (id TEXT PRIMARY KEY, for_agent TEXT, issued_by_hash TEXT, mandate_json TEXT, valid_from TEXT, valid_until TEXT, revoked_at TEXT);
CREATE TABLE adapters (adapter_id TEXT PRIMARY KEY, kind TEXT, secret_hash TEXT, cert_fingerprint TEXT, created_at TEXT, rotated_at TEXT);
CREATE TABLE policy_bundles (hash TEXT PRIMARY KEY, version TEXT, loaded_at TEXT, rules_count INT, diff_json TEXT, loaded_by_hash TEXT);
-- `history_daily` was specified here and created by migration 0001. Nothing
-- ever read or wrote it: it describes per-principal, per-target, per-day
-- aggregates, where the two rules that need history ask for a rolling window
-- over the chain instead. Migration 0006 drops it and adds what the counters
-- actually need. An empty table would now be worse than none, because it would
-- read as a second source for numbers that come from `evidence`.
ALTER TABLE evidence ADD COLUMN decision TEXT; -- 0006; verdict records only
CREATE INDEX ix_evidence_principal_ts ON evidence(principal_hash, ts);
SELECT pg_try_advisory_lock(1734440037) (= 0x67617465, ASCII "qed") for chain leader. No ORM; thin repository functions with parameterized SQL; migrations applied at startup (forward-only).
16. Configuration (qed.yaml reference)¶
instance_id: acme-prod-qed # used in genesis hash; stable
mode: shadow | enforce # service default (ADR-003)
default_verdict: ALLOW # ADR-002.1
environment_profile: prod | dev # affects adapter fail defaults
identity:
issuers: [{ iss: https://acme.okta.com, jwks_cache: /data/jwks/okta.json }]
token_key: file:///data/token-key.pem
token_ttl_default: 30m # attended tokens; hard cap 60m
max_chain_depth: 4
scopes_by_group: { developers: ["mcp:*:read", "mcp:github:*"], sre: ["http:aws:*", "mcp:*"] }
budget_caps_by_group: { developers: { steps: 100, tokens: 500000, seconds: 3600 } }
mandate_signers: [oncall-leads]
approvals:
channels: [slack, web]
slack: { bot_token_env: SLACK_BOT_TOKEN, signing_secret_env: SLACK_SIGNING_SECRET, channel: "#agent-approvals" }
default_timeout: 10m
window_default: 15m
window_max: 60m
dedupe_window: 10m
deny_reason_required: true
evidence:
store: sqlite:///data/qed.db | postgresql://…
signing_key: file:///data/evidence-key.pem | vault-transit://qed-evidence | awskms://alias/qed (ECDSA_P256)
checkpoint: { every_records: 1000, every_seconds: 3600, publishers: [{ type: s3-object-lock, bucket: acme-qed-anchors, mode: COMPLIANCE, retain_days: 2555, endpoint_url: "", region: "" }, { type: gcs-retention, bucket: acme-qed-anchors, prefix: "checkpoints/", require_locked: true, project: "" }, { type: file, path: /data/anchors }] }
# The `gcs-retention` publisher is the GCP twin of `s3-object-lock`: a *locked* GCS retention policy is the analogue of object-lock COMPLIANCE, and an *unlocked* one is the analogue of GOVERNANCE. Retention in GCS is a property of the bucket rather than of the upload, so this publisher asks for nothing and instead reads the policy back and records which of the three states (none, unlocked, locked) it found. It never sets or locks a policy: the anchor is worth something because the bucket is outside this instance's control, so the bucket is created and locked by the platform, not by the kernel. `require_locked: true` (the default) makes anything less a caveat in the receipt and therefore in the chain. Credentials come from Application Default Credentials, never from this file; `project` is optional because bucket names are global and an anchor bucket usually lives in another project, which is the topology that makes it an anchor.
# `endpoint_url` points the s3-object-lock publisher at an S3-compatible store inside the organisation (MinIO, Ceph, a gateway), which is the deployment a self-hosted kernel wants: the anchor has to be outside *this instance's* control, not outside the building. Setting it forces path-style addressing, because a virtual-host URL (`bucket.host`) does not resolve against anything but AWS. `region` is for signing. Credentials come from the environment, never from this file.
retention: { payload: 7y, default: forever }
masking: default | strict
adapters:
mcp: { fail: closed, decision_timeout: 2s }
http: { fail: closed, mode: host_path }
hooks: { fail: open_in_dev }
rate_limit: { per_adapter_rps: 100, per_principal_rps: 20 }
broker: { upstreams_file: /etc/qed/upstreams.yaml, secrets: { vault: { addr: https://vault.acme, auth: kubernetes } } }
remote: # all optional, outbound-only, hashes only
anchor: { enabled: false, url: https://anchor.qed.dev }
packs: { enabled: false, url: https://packs.qed.dev, public_key: … }
intel: { enabled: false }
observability: { metrics: true, events_sse: true, siem: { format: ocsf, webhook: https://splunk… } }
history: { refresh_seconds: 60 } # §5; 0 = exact, one query per decision
ui: { enabled: true, oidc_client_id: … }
qed.yaml; only env names or secret-store refs.
17. Security requirements and threat model summary¶
Full STRIDE in THREAT_MODEL.md. Key MUSTs:
- Service refuses to start without evidence signing key and token key (readyz=false).
- Admin operations (policies:reload, keys:rotate, upstream changes, adapter add) go through the approval service with SoD and produce admin_action evidence — qed governs itself. Built in week 10 (ADR-0014) for keys_rotate and packs_import, joined by adapters_add and adapters_rotate when those commands were built on 2026-10-15 (registering an adapter grants an identity the right to submit proposals, which is the §17 list's "adapter add"). The default admin.operations stays the original two: widening it would gate operations an existing instance was not gating yesterday, on upgrade, with no edit to its config. admin.self_governed with admin.approvers, reusing the same ApprovalRequest and check_separation_of_duties as an agent's action. Off by default (it needs a second person; qed doctor reports which it is either way, and the record carries self_governed: false rather than omitting it). An approval is bound to one operation and spent once, read back out of the chain. --break-glass proceeds and records that it did — a gate with no break-glass moves the rotation outside the tool, where nothing records it. On the CLI the requester is self-asserted, so what this buys there is two invocations by two subjects, not proof of identity; the API path proves it with a token.
- Broker credentials in memory only; redact in all logs; never in evidence or events.
- Adapter authentication mandatory; unknown adapters rejected.
- Replay: duplicate proposal.id → 409 and correction-free rejection record.
- Clock: warn if NTP drift > 5 s; seq guarantees order regardless of ts. Built in week 10, with one deviation stated in its own output: it does not query NTP (the kernel is offline by design, invariant §1.7) and instead compares the newest record's ts against the system clock, which catches a clock that went backwards. Weaker signal, no network. Reported by qed status and qed doctor; never enforced, because halting on a clock would trade a cosmetic problem for an outage.
- Input limits: proposal ≤ 64 KB; params_redacted ≤ 16 KB; regex evaluation with timeouts (use re2-style guard via regex module timeout). Fuzzed in week 10 (tests/test_fuzz_parsers.py, hypothesis): JCS, the proposal schema, the policy loader, the pack-bundle envelope and the config loader, each asserting that a malformed input yields the refusal its caller handles and nothing else. It found two leaks — raw yaml errors out of the policy loader, and UnicodeDecodeError out of the pack-bundle reader.
- Supply chain: SBOM (Syft), cosign keyless signing, SLSA provenance; qed doctor verifies own binary signature when possible.
- Dependencies pinned; pip-audit in CI. Built in week 10: its own job, on push and on a daily schedule, auditing both the installed environment (transitives included, not --strict because our own packages are editable installs) and the declared dependency set (--strict, so nothing is quietly skipped).
18. Observability¶
- Events:
GET /v1/eventsSSE; each event = redactedEvidenceRecordsummary (seq, ts, type, decision, rule_ids, principal_display?, agent, action.name, target, environment, assurance, mode). Backfill via?from_seq=. - Metrics (Prometheus):
qed_decisions_total{decision,mode,environment,adapter_kind},qed_decision_latency_seconds(histogram; target p95 < 50 ms),qed_approvals_total{decision},qed_approval_wait_seconds,qed_budget_exhausted_total,qed_chain_lag_records(outbox depth),qed_chain_last_checkpoint_age_seconds,qed_ungated_windows_total,qed_no_rule_matched_ratio,qed_decisions_rule_skipped_total(a rule's predicate could not be evaluated). - SIEM: OCSF-shaped JSON (class
Authorization/API Activitymapping documented indocs/siem.md) via webhook or stdout; CEF optional.
19. Live UI (separate AGPL package qed-ui from repo valqore/qed-ui; served by qed serve --ui)¶
The Apache kernel wheel contains no UI code (license separation). pip install valqore-qed[ui] pulls the qed-ui wheel; qed serve --ui serves its static assets if the package is importable, otherwise prints how to install it. Single-page, no build step at runtime (prebuilt assets inside the qed-ui wheel). Pages:
1. Live — decision stream (SSE), filter by decision/agent/env; chain status badge (seq, last checkpoint age, verify ok).
2. Approvals — pending list with full context (§9.2), buttons Approve once / Approve for N / Deny.
3. Budgets — active tokens with bars (steps/tokens/time).
No analytics, no trends. SSO via OIDC (same issuers). Read-only except approval buttons.
20. Repository, tooling, standards¶
20.1 Layout¶
qed/
pyproject.toml README.md SPEC.md(this) GOVERNANCE.md SECURITY.md THREAT_MODEL.md CHANGELOG.md LICENSE(Apache-2.0)
qed/
core/ schemas.py policy/{engine.py,loader.py,lint.py,predicates.py,blast_radius.py,packs/agent-baseline/*.yaml}
delegation/{verify.py,exchange.py,chain.py} mandate/{model.py,service.py}
budget/ledger.py approval/{service.py,sod.py,dedupe.py} evidence/{record.py,hashing.py,signing.py,merkle.py,export/{jsonl.py,csv.py,oscal.py}}
broker/resolve.py
infra/ storage/{sqlite.py,postgres.py,migrations/} chain_writer.py keys/{file.py,vault_transit.py,awskms.py}
secrets/{env.py,file.py,vault.py} jwks.py channels/{slack.py,web.py,cli.py,webhook.py} publishers/{s3.py,git.py,file.py,email.py}
api/ app.py auth.py routers/{proposals,token,approvals,evidence,policies,mandates,upstreams,admin,events,health}.py
adapters/ common.py mcp/ hooks/{claude_code.py,codex.py} http_proxy/ sdk/ ci.py
compliance/ frameworks.yaml packs/*.yaml loader.py
remote/ anchor.py packs.py intel.py buildinfo.py
explain/ (optional local model; never imported by core)
cli.py
packages/qed-verify/ (own pyproject; stdlib + cryptography)
deploy/ Dockerfile docker-compose.yml(keycloak, postgres, qed) helm/qed/
examples/ keycloak-local/ okta-xaa/ aws-agentcore/ langgraph/ crewai/ demos/prod-db-drop/
docs/ evidence-format.md policy-language.md blast-radius.md assurance-classes.md onboarding-shadow-to-enforce.md siem.md adr/
tests/
20.2 Standards¶
Python 3.12+, ruff (lint+format) and mypy --strict mandatory in CI; from __future__ import annotations; no Any in qed.core; docstrings on public functions; 100% of qed.core covered by tests; conventional commits; semver; CHANGELOG.md in repo.
20.3 Dependencies (kernel wheel) — MUST NOT exceed without spec update¶
fastapi, uvicorn[standard], pydantic>=2, authlib (JWT/JWKS/OAuth), cryptography, typer, httpx (infra/adapters only), pyyaml, mcp (adapters only), python-ulid, regex, keyring. Optional extras: [postgres] psycopg[binary], [vault] hvac, [aws] boto3, [slack] slack-sdk, [mitm] mitmproxy, [ui] qed-ui (AGPL, separate wheel). Dev/test only: pytest, pytest-asyncio, hypothesis, ruff, mypy, jsonschema, pyinstaller (week 10 — §24.1 already names it as the mechanism for the Homebrew single-file binary; it is never imported by qed, and tests/deploy/test_binary.py builds the binary and drives it), mkdocs + mkdocs-material (week 11 — §26 already names mkdocs-material as the docs site; capped below MkDocs 2.0, which removes the plugin system with no migration path, and tests/docs/test_site.py builds the site with --strict).
qed-verify: cryptography only.
Pins and notes (added week 2, per §23 "no new dependency without a §20.3 update"):
- mcp>=2.3,<3. The 2.0 SDK replaced the decorator-based lowlevel.Server with on_list_tools / on_call_tool callbacks, which qed/adapters/mcp/proxy.py uses. A 1.x resolution does not import.
- jsonschema is dev/test only and validates the OSCAL export against the NIST schema vendored in tests/oscal/. §10.8 permits JSON Schema in place of oscal-cli; oscal-cli would put a JVM in CI for one check. Note that the NIST schema uses ES2018 Unicode property escapes (\p{L}), which CPython's re cannot compile, so the test supplies regex as the pattern engine — see tests/oscal/__init__.py.
- mcp pulls httpx2 transitively as of 2.3. It is a transport dependency of the SDK, not a direct import of qed, and httpx is already permitted above for infra and adapters.
Week 3 amendment — JOSE library. §20.3 names authlib for JWT/JWKS/OAuth. As of
authlib 1.8 the authlib.jose module is deprecated in favour of joserfc (same
author) and emits AuthlibDeprecationWarning on import, which this suite treats as an
error. The allowed set therefore reads joserfc where it read authlib; this is a
rename of the same capability, not an additional dependency. authlib remains the name
to use if OAuth client flows are ever needed outside JOSE.
Token algorithm. QED-issued tokens are signed alg: Ed25519, not EdDSA:
RFC 9864 deprecates the generic EdDSA JWS identifier in favour of the concrete curve
name, and joserfc raises SecurityWarning on EdDSA. The identifier is baked into
every token QED mints, so it is settled before the first one is issued rather than
after. Evidence signing is unaffected — it is raw Ed25519, not JOSE.
20.4 Packaging¶
PyPI valqore-qed (CLI qed), qed-verify; container ghcr.io/valqore/qed (distroless, non-root, cosign); Homebrew tap valqore/tap with single-file binary (PyInstaller via pyoxidizer/nuitka evaluated in week 5; fallback: brew formula depending on python@3.12); Helm OCI chart.
21. Testing strategy¶
| Suite | What | QED |
|---|---|---|
tests/core/test_no_network.py |
AST scan: no http libs/sockets imported under qed.core |
CI fail |
tests/core/test_no_llm.py |
no anthropic/openai/llama imports under qed.core |
CI fail |
tests/policy/golden/*.json |
≥100 proposals × expected verdict/hits; run twice, shuffle rule order, compare bytes | CI fail |
tests/policy/test_lint.py |
all lint error classes | |
tests/evidence/test_chain.py |
genesis, linkage, tamper detection (edit/delete/reorder), rotation continuity, purge-then-verify | |
tests/evidence/test_merkle.py |
roots, odd leaves, receipts | |
tests/evidence/test_oscal_schema.py |
export validates against NIST schema (vendored) | CI fail |
tests/masking/ |
PII/secret fixtures masked at write | |
tests/delegation/ |
chain narrowing matrix, expiry/skew, mandate required/within/revoked | |
tests/approval/ |
state machine, SoD, dedupe, windows, escalation, expiry | |
tests/budget/test_concurrency.py |
50 threads decrementing; exact counts | |
tests/adapters/e2e/ |
docker-compose: keycloak + postgres + qed + sample MCP server; Claude Code hook simulated via stdin fixtures; HTTP proxy with httpbin | nightly + PR label |
tests/perf/ |
1k rps proposals SQLite/Postgres; p95 assertion | nightly |
packages/qed-verify/tests |
verifies fixtures produced by kernel; cross-version fixtures | CI fail |
22. Build order and milestones (v0.3 — early launch)¶
Two engineers. Repo public from day 1. Weekly tagged release from week 2.
| Week | Deliverable | Exit criterion |
|---|---|---|
| 1 | Repo skeleton, CI (ruff/mypy/pytest/no-network test), schemas.py, hashing/signing/merkle, evidence ledger (SQLite), qed-verify v0, keys init, policy loader+lint+engine with 15 baseline rules, golden tests |
qed check proposal.json → signed chain → qed-verify passes; 60 golden tests green |
| 2 | MCP proxy adapter (port from Valqore mcp/gateway.py, decisions via kernel, journal removed), API /proposals, /evidence, shadow mode, qed serve, Dockerfile, OSCAL export (baseline-rule control refs only; full packs week 8) + schema test |
v0.1.0 public: qed mcp -- <server> in shadow against a real MCP server; blog post #1 ("why static skill scanning is not enough") |
| 3 | Delegation: JWKS verify, /token exchange, chain narrowing, Keycloak compose, qed login device flow, budgets ledger, remaining baseline rules (~50), policy test/explain |
Okta/Keycloak user → budgeted token → MCP call → verdict; v0.2.0 |
| 4 | Approval service: state machine, SoD, dedupe, windows, timeouts; CLI + web approval page; Slack channel; /events SSE; live UI (3 pages) |
Demo: prod-delete → Slack → deny by different human → chain; v0.3.0; design partner #1 installed (shadow) |
| 5 | Credential broker (env/file/Vault), enforcing MCP; HTTP forward proxy (host_path); mandates (sign/list/revoke, unattended flow); qed doctor posture |
Unattended SRE-agent demo with mandate + budget exhaustion; v0.4.0; brew tap (python-dependent formula) |
| 6 | Claude Code hook + install; Codex hook/wrapper; Python SDK + LangGraph/CrewAI examples; qed check CI mode; Postgres + chain leader/outbox; Helm |
Four adapters e2e; HA test; Show HN + blog #2 ("Replit/Supabase/AgentCore: what a gate would have done"); v0.5.0; design partner #2 |
| 7 | Checkpoints + publishers (file, S3 object-lock, git); key rotation; retention/purge; masking strict; OCSF SIEM webhook; perf suite | p95 < 50 ms verified; purge-then-verify passes; v0.6.0 |
| 8 | Compliance packs import + normalization; OSCAL control mapping complete for EU AI Act/NIST AI RMF; assurance classes doc; auditor session #1 | Written auditor feedback; fixes scheduled; v0.7.0 |
| 9 | Auditor fixes; ISO 42001/DORA deepening; qed.remote.packs (signed) + air-gap import; qed.remote.anchor client (server stub) |
Auditor "acceptable (with changes)" → changes merged; v0.8.0 |
| 10 | Hardening: threat model doc, admin self-governance (approval on reload/rotate), rate limits, fuzz on parsers, pip-audit; single-file binary for brew | v0.9.0; both design partners in enforce on ≥1 upstream |
| 11 | Docs site, onboarding guide, examples polish, SECURITY.md disclosure process, GOVERNANCE.md; YC application draft | Docs complete; ≥1,000 real actions gated across partners |
| 12 | v1.0.0: freeze evidence format 1.0, publish docs/evidence-format.md as spec; landing page; Valqore site banner & copy fixes (§16 of plan v0.2) |
2 partners enforce; 1 auditor written acceptance; ≥300 stars; YC application submitted |
Parallel every week: 2 customer/auditor conversations; weekly changelog; no competitor research.
23. Definition of done (per PR)¶
- Tests added/updated;
qed.corecoverage unchanged or higher. ruff+mypy --strictclean.- If
schemas.pyor hashing changed: fixtures regenerated and old fixtures still verify (format compatibility) orschema_versionbumped with ADR. - Docs touched when behavior changed; CHANGELOG entry.
- No new dependency without §20.3 update.
24. Open items waiting on business input (do not block weeks 1–3)¶
- ~~Final product name and domain~~ → closed 2026-10-08. The product is "Valqore QED";
qedstays the command, the import package and the repository name. No refactor needed: ADR-0006 already removed the product name from the evidence format. Domain and the PyPI/npm/GHCR/Homebrew/EUIPO/USPTO reservations are a separate task and they gate the first public push, not the build. PyPI, npm and the Homebrew tap were reserved 2026-10-17 (valqore-qed,qed-verifyandvalqoreon both indexes, each a0.0.0placeholder that installs nothing;valqore/homebrew-tap, private, holding the rendered formula). A publicghcr.io/valqore/qedand the two trademark filings remain open. - ~~Valqore patent scope (I5)~~ → closed 2026-10-08: no filings, nothing pending. The broker+mandate+chain composition practises no claim Valqore holds, so the irrevocable Apache-2.0 §3 grant transfers nothing. Apache-2.0 is now applied (
LICENSE,NOTICE, SPDXlicensein bothpyproject.tomlfiles). - Existing Valqore agent-qed users (I1/B9) → migration note or none.
- KMS choice for design partner #2 (AWS KMS forces ECDSA P-256 variant).
- Auditor candidates (3) and design partners (2 of 5) confirmed.
25. Publishing and open-source strategy¶
25.1 Repositories and licenses (three repos, three licenses — fixed from day 1, never narrowed later)¶
| Repo | License | Contents | Why |
|---|---|---|---|
github.com/valqore/qed |
Apache-2.0 | kernel, adapters, policy language, baseline pack, qed-verify, docs, Helm, examples |
Trust layer must be readable; enterprise legal accepts Apache without review |
github.com/valqore/qed-ui |
AGPL-3.0 + CLA | live UI, approval pages, SSE client | Prevents a third party from offering a hosted "qed-as-a-service" UI without releasing changes; customers running it internally are unaffected |
github.com/valqore/qed-cloud |
proprietary (private) | multi-tenant evidence vault, auditor portal, transparency log (anchor), signed pack feed, SSO/RBAC, long retention |
The revenue layer; never mixed into the open repos |
Rules: no proprietary code ever enters the Apache repo (git history is irreversible); Valqore-ported modules arrive as one imported from valqore-engine e7e66a3 commit without private history; gitleaks in CI on all repos; DCO (Signed-off-by) on qed, CLA on qed-ui; trademark clause per Apache §6 — forks may not use the product name/logo; register the name (EUIPO + USPTO) and reserve PyPI, npm, Homebrew tap, Docker Hub/GHCR, GitHub org names before the public launch.
25.2 Open-core boundary — the five tests (goes into GOVERNANCE.md)¶
A component is open and local if any of: (1) it is in the decision path; (2) it carries customer data; (3) auditors must verify it independently. It may be a remote/optional API if (4) its value grows with time or network (freshness, external witnessing, shared intel). It is closed/commercial if (5) it only makes sense at scale (multi-tenant, multi-auditor, multi-year). Red lines: the kernel alone, offline, produces auditor-acceptable evidence; no commercial feature ever removes or caps an open capability (no "30-day chain in free").
25.3 Relationship to Valqore¶
One-directional: qed has zero imports from Valqore. Valqore becomes a consumer in v1.x (qed.propose(kind="iac")) and deletes its own approvals.py/mcp/gateway.py then. Valqore site: top banner + shortened "Govern Autonomous Agents" section linking to qed; ADP page retired or redirected; copy fixes from plan v0.2 §16 (remove "HMAC-signed", "signed delegation", "188 controls", "no competitor ships this"; align the three inconsistent rule counts). Valqore remains the IaC/K8s/FinOps product with its own narrower sentence; qed gets its own domain (.dev/.sh) and single-sentence landing page.
25.4 Distribution channels¶
PyPI (valqore-qed, qed-verify); GHCR container (distroless, non-root, cosign keyless, SBOM); Homebrew tap valqore/tap (week 5 python-dependent formula → week 10 single-file binary); Linux curl | sh + .deb/.rpm (week 10); Helm OCI chart; later: AWS/Azure Marketplace listings, Okta Integration Network, official MCP Registry entry, Backstage plugin.
25.5 Launch sequence¶
| When | What | Channel |
|---|---|---|
| Day 1 | Repo public (skeleton + SPEC + GOVERNANCE) — "quiet open" | GitHub only |
| Week 2 | v0.1.0 MCP proxy in shadow + blog #1 "Static skill scanning is not enough — why runtime policy + signed evidence" |
MCP Discord/GitHub discussions, Claude Code community, r/mcp, LinkedIn |
| Week 4 | v0.3.0 approvals + live UI; 90-second demo video (prod-db-drop) |
Same + Twitter/X thread |
| Week 6 | v0.5.0 four adapters + HA; Show HN; blog #2 "Replit, Supabase MCP, AgentCore: what a gate would have done" |
Hacker News, Lobsters, newsletters (TLDR Sec, Last Week in AI) |
| Week 8 | Blog #3 "Evidence auditors can verify without trusting us" after auditor session | LinkedIn (CISO/GRC audience), ISACA/IIA groups |
| Week 12 | v1.0.0 + evidence-format 1.0 spec + landing page + design-partner quotes |
All channels; Product Hunt optional |
| Weekly: changelog post in GitHub Discussions; respond to every issue within 24 h (this is the moat in months 0–6). No conference travel; no paid ads. |
25.6 Versioning and compatibility promises¶
Semver. schema_version of ActionProposal/EvidenceRecord is independent of package version; any change to hashing or record fields bumps it with an ADR and keeps qed-verify able to verify all prior versions (cross-version fixtures in CI). Policy language changes are additive within 1.x. Deprecations announced one minor ahead.
26. Technology stack¶
| Layer | Choice | Rationale / alternatives considered |
|---|---|---|
| Language | Python 3.12+ | Team fluency; Valqore modules port directly; match allowed (no Cython). Go rewrite of the hot path is a v2 option if p95 target fails under load |
| API | FastAPI + uvicorn | Async, Pydantic-native, OpenAPI for free; already in Valqore |
| Schemas | Pydantic v2 (frozen, extra="forbid") |
Determinism and strictness |
| Auth/JWT/OAuth | authlib | JWKS, RFC 8693 exchange, device flow, OIDC discovery — replaces Valqore's hand-rolled RS256 |
| Crypto | cryptography (Ed25519, SHA-256); JCS canonical JSON (small vendored impl) | Standard library quality; qed-verify depends on it alone |
| Storage | SQLite (default) / PostgreSQL via psycopg3; raw SQL, forward-only migrations |
Mirrors Valqore (no ORM); SQLite = zero-config laptop; Postgres = HA with advisory-lock leader |
| Secrets/keys | env, file, HashiCorp Vault (hvac; Transit for Ed25519), AWS KMS (ECDSA P-256 variant), Azure Key Vault (v1.x) |
Regulated buyers already run Vault/KMS |
| MCP | official mcp Python SDK (stdio + Streamable HTTP) |
Protocol fidelity; Valqore used fastmcp — proxy needs client+server both, official SDK cleaner |
| HTTP proxy | custom asyncio CONNECT proxy (host/path mode); mitmproxy addon as optional MITM mode | Avoid reinventing TLS interception |
| Approvals | slack-sdk (Block Kit, signature verification); generic webhook; web via FastAPI | Teams native in v1.x |
| UI | Preact + htm (no build step, ~10 KB) or plain HTML + SSE; shipped as the separate qed-ui wheel (AGPL), served by the kernel when installed |
Two-person team: no frontend toolchain to maintain; license kept out of the Apache wheel |
| CLI | Typer + Rich | Same as Valqore |
| Packaging | PyPI wheels; PyInstaller/Nuitka single binary for brew (decide week 5); distroless container; Helm | Brew-first install for Mac users |
| CI/CD | GitHub Actions: ruff, mypy --strict, pytest (+coverage gate on qed.core), no-network/no-LLM AST tests, OSCAL schema validation, gitleaks, pip-audit, Syft SBOM, cosign keyless, SLSA provenance, multi-arch build |
Ported from Valqore CI plus lint/type steps it lacked |
| Docs | mkdocs-material, versioned; ADRs in docs/adr/ |
|
| Observability | Prometheus client; SSE; OCSF JSON | Customers keep their Grafana/Splunk |
| Testing | pytest, pytest-asyncio, hypothesis (predicates/hashing), docker-compose e2e (Keycloak, Postgres, httpbin, sample MCP server) | |
| Explicitly not used | ORMs (SQLAlchemy), Alembic, OPA/Rego, Cedar, LangChain, any LLM SDK in core, React build chain | Keep the kernel small and auditable |
27. Roadmap beyond v1.0¶
v1.x (months 4–6) — depth and reach¶
- Adapters: TypeScript SDK; Kubernetes admission webhook (port from Valqore); Valqore IaC as consumer; Gemini CLI / Cursor hooks as their hook APIs stabilize; Codex native hook.
- Identity: Okta XAA (ID-JAG) and AWS AgentCore OBO reference integrations; SPIFFE/SPIRE SVIDs for unattended agents; Azure Key Vault signing.
- Approvals: Microsoft Teams and PagerDuty native channels; mobile-friendly approval page; approval delegation (out-of-office).
- Evidence:
qed.remote.anchortransparency log GA (public, append-only, Rekor-style) with inclusion proofs inqed-verify; multi-chain HA (per-replica chains + merged checkpoints) if leader model limits throughput. - Compliance: ISO 42001 and DORA rule-level depth; SOC 2 CC6/CC7 mapping; FedRAMP 20x profile; OSCAL
component-definitionexport so customers can drop qed into their SSP. - Policy:
exceptionsconcept with expiry and owner (time-boxed, auditable allowlist alternative); policy simulation against historical chain (qed policy simulate --since 30d). - Ops: OpenTelemetry traces;
qed doctor --reportbundle for support.
v2 (months 7–12) — product surface and ecosystem¶
- Dashboards (commercial, qed-cloud / enterprise): fleet posture (agents × upstreams × enforcement class), approval SLAs and approver load, budget burn-down per team, "no-rule-matched" drift, policy change diffs with blast-radius impact; auditor portal where auditors verify chains and download OSCAL without customer system access; multi-tenant evidence vault with 7-year retention.
- Shared threat intelligence (opt-in, hashes only): malicious MCP server / skill hash blocklist and tool-pattern risk scores distributed to all installations.
- Signed policy pack marketplace: curated regulatory packs (EU AI Act implementing acts, DORA RTS updates) as a subscription; community packs with signatures.
- Benchmark (
qed-bench): public corpus of labeled agent-action proposals (incl. AgentDojo-style attacks) and per-release scores. - Standards: publish
agent-evidence-format/1.0as an independent spec (OWASP GenAI / IETF draft); conformance test-suite; reference implementations invited. - Programmatic onboarding (deferred from the original idea): MCP CIMD-based agent self-registration against qed; machine-readable service terms with counter-signed receipts once standards exist; outbound RFC 9421 / Web Bot Auth signing.
- Platform plugins: Okta Agent Gateway extension, AWS AgentCore Gateway interceptor, Cloudflare Worker variant of the HTTP proxy.
- Performance: optional Go/Rust hot-path for the MCP/HTTP proxies if Python p95 is insufficient at >5k rps.
Explicitly never¶
Becoming an IdP; executing agent actions ourselves; LLM-made decisions; telemetry without opt-in; license narrowing of the Apache core.
Appendix A — Example end-to-end fixture (examples/demos/prod-db-drop/)¶
proposal.json (Claude Code → postgres.execute DROP TABLE on prod), policies/ (baseline), expected_verdict.json (REQUIRE_APPROVAL, hits AG-BLR-003), approval_deny.json, expected_chain.jsonl (6 records), run.sh (starts qed with --dev, posts, decides via CLI as a different principal, exports OSCAL, runs qed-verify). This fixture is the README demo and the first golden e2e test.
Appendix B — Mapping from Valqore (what is ported)¶
| Valqore | qed | Change |
|---|---|---|
approvals.py chain logic |
core/evidence, core/approval |
Ed25519 only, key_id, schema_version, SoD, timeouts, windows |
mcp/gateway.py |
adapters/mcp + parts of core/policy |
decisions via kernel; JSONL journal removed |
verify.py |
packages/qed-verify |
new format; checkpoints; purge-aware |
compliance/packs.py, oscal.py |
compliance/, core/evidence/export/oscal.py |
normalized frameworks; schema-validated; assurance observations |
governance/blast_radius.py |
core/policy/blast_radius.py |
K8s specifics removed; table documented |
governance/agent_budgets.py |
core/budget |
keyed by token_jti; token-bounded |
| the predecessor's rule registry, IaC/K8s parsers, FinOps, carbon, PQC, what-if and AI explainer | not ported | — |