LLD: Freshness Gaps — Mid-Session Guardrail Re-pin + Decision Receipts
Status: Draft — FOUNDER-GATED (awaiting founder sign-off) Version: 1.0 Architect: Principal Technical Architect Issue: #1424 (parent #1418 / ADR-0019 §5) Related: #1184 (Agent Decision Receipt brainstorm), ADR-0012 (decision receipt = the bound public artefact), ADR-0019 (adopt-and-wrap; hands these two gaps to this HLD/LLD) Milestone: #20 (Runtime Revamp — Adopt & Wrap)
Why founder-gated. The decision-receipt predicate is ADR-0012's bound public artefact — a Specialization-3 moat the founder committed to ship within 90 days, with a patent carve (sentence-to-bytecode compiler, selective-disclosure Merkle scheme) and an open-spec / IETF-SCITT registration ambition. The predicate's field set and the open-vs-patent line are strategy decisions, not just engineering. This LLD designs the in-product predicate + reference verifier and explicitly scopes MVP vs. later; the strategic commits (publish spec, file patents, SCITT registration) remain founder calls.
1. Executive Summary
Close the two freshness gaps ADR-0019 §5 explicitly handed downstream, without weakening read-through-per-call freshness or tenant isolation:
-
Mid-session guardrail re-pin. Today
GuardrailHashesis pinned once at session init (internal/runtime/session/initializer.go:165-169). ThePromptIntegrityVerifier(internal/runtime/security/integrity.go) calls Context EngineValidatePromptwith those pinned hashes on everySendMessage. If an admin tightens a guardrail mid-run, the Context Engine's current layer hashes no longer match the pinned snapshot,ValidatePromptreturnsvalid=false, and the verifier emitsSeverityBlock— hard-failing a long autonomous run instead of adopting the new, stricter guardrail. We design a turn-boundary re-pin so a long run adopts tightened guardrails. Read-through evaluation of policy/guardrail is untouched — it stays always-fresh, no cache. -
Decision receipts (#1184, ADR-0012 public artefact). Today the audit chain records the verdict but not the rule-state that produced it — we cannot replay a 30-day-old decision bit-for-bit (#1184 product-need check). We design the receipt predicate anchoring five facts none of the prior-art specs anchor — policy-bundle content hash, matched-policy content hash, guardrail-compiler version, member-state hash, public-Merkle inclusion proof — so each governed action (each autonomous-loop hop and each A2A edge) can attest which policy version judged it, and ship a reference verifier. The receipt rides the existing audit hash-chain (migration
036,internal/runtime/audit) rather than replacing it.
The two pieces share one root insight: a decision is only as trustworthy as the freshness of the rules that judged it and the durability of the proof that those were the rules. Re-pin keeps the rules fresh; receipts make the proof durable.
2. Code Anchors (verified on origin/main @ c65d1b7)
| Concern | File:line | Today |
|---|---|---|
| Guardrail hash pinning | internal/runtime/session/initializer.go:165-169 | Pinned once at session init from immutable layers L1-L4 |
| Integrity verify (per-call) | internal/runtime/security/integrity.go:61-112 | Calls ValidatePrompt with snap.GetGuardrailHashes(); valid=false → SeverityBlock |
| Pre-dispatch pipeline | internal/runtime/security/pipeline.go:271-295 | Runs integrity per SendMessage; fail-OPEN on RPC error, fail-CLOSED on valid=false |
| Embedded PDP | internal/governance/engine.go (in-process lib, engine.Check()) | Read-through-per-call; 4-layer cascade via cascade.go |
| Cascade resolve | internal/governance/cascade.go (CascadePolicySet) | Single fetch, tightest-first walk, sticky-deny |
| Audit hash-chain | internal/runtime/audit/{writer,chain,hashchain,verifier}.go; migration 036_audit_log_hash_chain | row_hash = SHA256(prev_hash ‖ JCS(content)), session-scoped, epoch semantics, flag audit.chain_enabled |
| Audit entry | internal/runtime/audit/writer.go Entry{} | Has Detail, InputHash, OutputHash, ProvenanceChain; no policy/guardrail/evaluator hashes |
| ValidatePrompt RPC | pkg/contextpb/.../context.pb.go ValidatePromptRequest{Scope, LayerHashes} → {Valid, ViolationCode, ViolationDetail} | Stateless compare against current layers |
| Compiled guardrail layers | internal/context/guardrail/engine.go (ORDER BY layer_level, version DESC) | Layers carry a version; no compiler-version pin recorded |
3. Gap 1 — Mid-Session Guardrail Re-pin
3.1 Failure today (drift tripwire fires on a tightening, not tampering)
ValidatePrompt is a binary "do these pinned hashes still match the live layers?" check. It cannot distinguish:
- Tampering / corruption — DB row mutated out from under the session (the threat the verifier was built for) → must block.
- Legitimate admin tightening — an operator pushed a stricter guardrail mid-run → today this also blocks, which is wrong: the run should adopt the stricter rule, not die.
Both present identically as valid=false. The fix is to give the verifier enough information to distinguish a forward, monotonic guardrail update from a sideways/backward mutation, and to re-pin on the forward case at a safe boundary.
3.2 Design — turn-boundary re-pin with a monotonic-version guard
We do not re-pin mid-token or mid-tool-call (that would let a hash flip race a streaming decision). We re-pin at a turn boundary — the natural decision point the autonomous loop already parks at (HLD §7.4 "between-step pause"). Concretely:
3.2.1 New Context Engine RPC: ResolveGuardrailLayers (additive; no break to ValidatePrompt)
rpc ResolveGuardrailLayers(ResolveGuardrailLayersRequest) returns (ResolveGuardrailLayersResponse)
message ResolveGuardrailLayersRequest { ScopePath scope = 1; }
message ResolveGuardrailLayersResponse {
repeated GuardrailLayer layers = 1; // L1-L4, current
}
message GuardrailLayer {
int32 layer_level = 1; // 1..4
string content_hash = 2; // current SHA-256
int64 version = 3; // monotonic per (scope, layer_level)
int64 effective_at = 4; // unix ms the version became active
}
This returns the same layer set ValidatePrompt compares against, but labelled with the monotonic version that already exists in the guardrail store (engine.go ORDER BY ... version DESC). The version is the freshness oracle.
3.2.2 Re-pin decision (in the orchestrator, between steps)
At each turn boundary, before issuing the next ExecuteStep, the orchestrator runs a cheap re-pin check:
current := ResolveGuardrailLayers(scope) // read-through, ~3-5ms, co-located
for each layer:
if current.version > pinned.version: // FORWARD update → adopt
re-pin snapshot guardrail hash for that layer
emit audit event guardrail_repin{layer, old_hash, new_hash, old_ver, new_ver}
if current.version < pinned.version: // BACKWARD/rollback → block (suspicious)
treat as drift; SeverityBlock; require operator ack
if current.version == pinned.version && hash differs: // SAME version, different bytes → TAMPER
treat as drift; SeverityBlock (this is the original threat)
The monotonic version is what lets us separate "operator tightened the rule" (forward, adopt) from "someone mutated the bytes of a version we already pinned" (tamper, block). A rollback (backward version) is deliberately treated as drift-block-and-ack — rolling a guardrail looser mid-run on a running autonomous agent is exactly the case a human should confirm.
3.2.3 Re-pin is monotonic-tighten-safe by construction. Because we only auto-adopt forward versions and the guardrail store is append-only-versioned, an autonomous run can only ever move to a newer guardrail set between turns; it can never silently weaken. The immutable session snapshot's config (model, tools, persona) is untouched — only the GuardrailHashes field is re-pinned, and that field is explicitly the live-integrity oracle, not part of the immutable behaviour contract. This preserves the "immutable session" tenet (HLD §7 matrix) because guardrails are a constraint envelope, not session behaviour: tightening the envelope mid-run is the desired safety property.
3.2.4 Read-through is untouched. Per-SendMessage evaluation still calls ValidatePrompt against the (now possibly re-pinned) hashes — always fresh, no cache. Re-pin only changes what the pinned baseline is, computed read-through at the turn boundary. No guardrail decision is ever served from cache.
3.2.5 Soft re-pull fallback. If ResolveGuardrailLayers RPC errors (Context Engine momentarily down), the orchestrator does not block the turn — it keeps the existing pinned hashes and lets the per-call ValidatePrompt continue to fail-OPEN as today (pipeline.go:275). Re-pin is a freshness improvement, never a new availability dependency. Graceful degradation: stale-but-safe (the previously pinned guardrail still applies).
3.3 Tenant isolation
ResolveGuardrailLayers carries ScopePath{OrgId, AgentId} exactly like ValidatePrompt; the Context Engine resolves under the same RLS GUC (app.org_id). No cross-tenant surface added.
3.4 Audit
Every re-pin emits a guardrail_repin audit action (child of the current session chain) recording {layer_level, old_hash, new_hash, old_version, new_version, effective_at}. This makes "the run adopted GR-01 v4 at turn 37" replayable — and feeds directly into the decision receipt's guardrail block (§4).
4. Gap 2 — Decision Receipts
4.1 The predicate (in-product, the ADR-0012 public artefact)
A decision receipt is the per-governed-action attestation that anchors which policy version judged this action. We adopt the #1184 shape (in-toto Statement v1, predicate type https://upsquad.ai/agent-decision/v1) as the wire/export format, but the in-product receipt is the predicate body persisted alongside the audit row. Fields, grouped by what each answers:
{
"verdict": "allow | deny | requires_approval",
"verdict_reason": "...",
"action": {
"type": "tool_call | llm_request | handoff", // handoff = A2A edge
"target": "github.create_pr",
"args_hash": "sha256:...", // RFC 8785 (JCS) canonical hash of args
"args_disclosed": false // hash-only by default (privacy)
},
"actor": {
"agent_did": "did:upsquad:agent:...",
"member_id": "n4",
"clearance_at_decision": "sensitive",
"member_state_hash": "sha256:..." // GAP-1: clearance+team+scopes+role snapshot
},
"policy": {
"policy_bundle_hash": "sha256:...", // GAP-2: content hash of resolved CascadePolicySet
"matched_policy_id": "pol_refund_ceiling_v3",
"matched_policy_hash":"sha256:...", // GAP-3: content hash of the matched rule
"cascade_layer": "team | org_unit | org | platform"
},
"guardrail": { // present when a guardrail layer participated
"guardrail_id": "GR-01",
"guardrail_sentence_hash": "sha256:...",
"guardrail_compiler_version": "1.2.0", // GAP-4: sentence→bytecode compiler pin
"compiled_predicate_hash": "sha256:..."
},
"evaluator": {
"engine_version": "1.4.2", // governance.Engine build version
"engine_commit": "c65d1b7",
"decided_at": "RFC3339Nano"
},
"transparency": { // LATER (v1.1) — public Merkle proof
"log": "https://rekor.sigstore.dev",
"log_id": "...",
"inclusion_proof": { /* GAP-5: Merkle witness */ }
}
}
The five GAP-tagged fields are precisely the axes ADR-0012 §Specialization-3 names that no competitor anchors.
4.2 Where the bytes come from (no extra DB round-trip)
| Field | Source — already in-hand at decision time |
|---|---|
policy_bundle_hash | cascade.go CascadePolicySet is the bundle the engine already loaded for this Check. Canonicalise (RFC 8785 over the resolved set, sorted by (layer, scope_id, policy_id)) and SHA-256 it in the same call — zero extra fetch. |
matched_policy_id / matched_policy_hash | Decision.PolicyID + the matched Policy row already walked in the cascade. Hash its canonical form. |
cascade_layer | Decision.DeniedBy / tightest-matched layer from Decision.CascadeTrace. |
member_state_hash | Deterministic JCS over (clearance, team_id, org_unit_path, scopes, role) — all present on Action (engine.go Action{Clearance, TeamID, OrgID, MemberID, ...}) + the resolved key already in the request. In-band, no round-trip. |
guardrail_* | From the §3.2 re-pin path: compiled_predicate_hash = the pinned layer hash; guardrail_compiler_version = a new constant stamped by the guardrail compiler (internal/context/guardrail). |
evaluator.* | Build-time ldflags version + commit, already standard. |
args_hash | JCS hash of the tool/LLM args — computed at the pre-tool hook. |
Key constraint honoured: the bundle hash is computed from the exact bytes the engine evaluated, captured inside engine.Check() — so it is the real rule-state, not a re-fetch that could race a mid-run policy change. This is what makes bit-for-bit replay sound.
4.3 Integration with the live PDP + audit chain
- The receipt predicate is emitted at the same hook the audit row is written — the embedded PDP pre-tool / pre-handoff hook (ADR-0019 §5).
engine.Check()returns aDecision; we extend the Decision-to-audit mapping to also populate a newReceiptPredicateon theaudit.Entry. - Storage: extend
agent_audit_log(migration111, additive nullable columns) withpolicy_bundle_hash,matched_policy_hash,member_state_hash,guardrail_compiler_version,evaluator_version, and areceipt_predicate JSONB. The existingrow_hashchain (migration036) already covers these new columns becauserow_hash = SHA256(prev_hash ‖ JCS(content))hashes the row content — adding fields to the content makes them tamper-evident for free. No new chain mechanism. - Freshness preserved: receipt fields are a recording of the read-through decision, computed from the bytes the live PDP evaluated. We add no cache and no second evaluation. The receipt is a side-effect of the existing Check, not a new decision path.
- Tenant isolation preserved: receipts live on
agent_audit_logrows under the same RLS as every other audit row. Export/verification runs per-tenant. The (later) public Merkle log publishes only hashes, never policy bytes or args (args_disclosed defaults false) — so a public transparency log leaks no tenant data.
4.4 Per-hop and per-A2A-edge coverage
ADR-0019 governs every autonomous-loop hop (per-call inside ToolNode) and every handoff (A2A edge, ADR-0015 / #1226). Because the receipt is emitted at the embedded-PDP hook and that hook fires per call inside the parallel-tool loop and per pre-handoff (HLD §7 matrix, §3 gotcha), each hop and each A2A edge gets its own receipt automatically. No additional plumbing — we ride the hook the runtime revamp already mandates.
4.5 Reference verifier
upsquad verify-receipt <bundle.json> (MVP, in-product, no Sigstore dependency):
- Fetch the audit row by
action_id; recomputerow_hashfromprev_hash ‖ JCS(content); assert it matches the stored chain → tamper-evidence. - Fetch policy bundle bytes by
policy_bundle_hash(from the content-addressed policy store, §5); recompute the cascade againstmember_state_hash+args_hash; assert the verdict matches → decision replay. - If a guardrail block is present, recompile the sentence with the recorded
guardrail_compiler_version; assertcompiled_predicate_hashmatches → guardrail replay. - (LATER) Verify Sigstore signature + Rekor inclusion proof → public non-repudiation.
Steps 1-3 are MVP and need no external service — they verify against our own chain and content-addressed stores. Step 4 is the v1.1 public-transparency layer.
5. MVP vs. Later
| Capability | MVP (this milestone) | Later (v1.1+, separate PRD) |
|---|---|---|
| Re-pin | Full: ResolveGuardrailLayers RPC, turn-boundary forward-adopt, monotonic-version guard, soft re-pull fallback, guardrail_repin audit | — (re-pin ships complete in MVP; it unblocks long autonomous runs) |
| Receipt predicate | All 5 anchor fields persisted on agent_audit_log (bundle hash, matched hash, member-state hash, compiler version, evaluator version) | — |
| Tamper-evidence | Free via existing row_hash chain (036) over the new columns | — |
| Content-addressed policy store | MVP: hash + persist the resolved bundle bytes keyed by policy_bundle_hash so the verifier can re-fetch | Selective-disclosure Merkle scheme (verify matched rule without disclosing the full bundle — patent carve) |
| Reference verifier | upsquad verify-receipt steps 1-3 (chain + replay + guardrail recompile), offline against our stores | Step 4: Sigstore signature + Rekor/SCITT inclusion-proof verification |
| Public transparency | Not in MVP | Sigstore Fulcio sign at emit, Rekor for public-tenant, SCITT stub for private-tenant; transparency block populated |
| in-toto / SLSA / SCITT registration | Predicate shape matches in-toto Statement v1 (so export is a re-wrap, not a re-design) | Register predicate type, file patents (compiler + Merkle), regulator briefings (EU AI Act Art. 12, SOC 2) — founder/strategy, ADR-0012 |
| Guardrail compiler reproducibility | Stamp guardrail_compiler_version constant; record compiled_predicate_hash | Reproducible bytecode + content-addressable artifact registry (patent carve) |
Rationale for the split. MVP gives an auditor local, offline, bit-for-bit replay today (the #1184 product-need: "we can't replay a 30-day-old decision"), with zero new external dependency and zero new SaaS — so it ships under the runtime-revamp milestone with no supply-chain escalation. The public-transparency and patent/spec layer (v1.1) introduces Sigstore/Rekor (new dependency + new egress) and the patent carve — both founder-gated by CLAUDE.md escalation rules and ADR-0012, so they are deliberately out of this LLD's build scope and into a follow-up PRD the founder triggers.
6. Task Breakdown + Dependencies
Re-pin is the smaller, self-contained piece and is on the critical path for long autonomous runs (it unblocks runs that would otherwise hard-fail on a mid-run tightening). Decision receipts is the larger piece and splits across proto, governance, storage, and a CLI.
Track A — Mid-session re-pin (smaller; unblocks autonomous runs)
| # | Title | Labels | Pts | Deps |
|---|---|---|---|---|
| A1 | BACKEND: ResolveGuardrailLayers proto + Context Engine impl (returns L1-L4 with monotonic version) | backend | 3 | — |
| A2 | BACKEND: turn-boundary re-pin in orchestrator (forward-adopt, backward/same-version drift-block, guardrail_repin audit) | backend | 5 | A1 |
| A3 | BACKEND: soft re-pull fallback + integrity-verifier version-aware violation codes (distinguish tamper vs. tightening) | backend | 3 | A1, A2 |
| A4 | QA: re-pin tests — forward-adopt, backward-block, same-version-tamper-block, RPC-down soft fallback, isolation | qa | 3 | A2, A3 |
Track B — Decision receipts (larger; the public artefact)
| # | Title | Labels | Pts | Deps |
|---|---|---|---|---|
| B1 | BACKEND: receipt predicate struct + RFC 8785 (JCS) canonicalisation helpers (bundle, matched-policy, member-state, args) | backend | 5 | — |
| B2 | BACKEND: compute hashes inside engine.Check() from the already-loaded CascadePolicySet (no extra fetch); populate Decision.ReceiptPredicate | backend | 5 | B1 |
| B3 | BACKEND: migration 111 — additive nullable receipt columns on agent_audit_log; confirm row_hash chain covers them | backend | 3 | — |
| B4 | BACKEND: emit receipt at PDP pre-tool/pre-handoff hook (per-call + per-A2A-edge); wire to audit.Entry | backend | 5 | B2, B3 |
| B5 | BACKEND: guardrail compiler-version stamp + compiled_predicate_hash (feeds receipt guardrail block; ties to A2) | backend | 3 | A2 |
| B6 | BACKEND: content-addressed policy-bundle store (persist resolved bytes keyed by policy_bundle_hash for verifier re-fetch) | backend | 5 | B2 |
| B7 | BACKEND: upsquad verify-receipt reference CLI (steps 1-3: chain recompute + decision replay + guardrail recompile) | backend | 8 | B4, B6 |
| B8 | QA: receipt tests — field population per verdict, replay equivalence, chain tamper-evidence over new columns, A2A-edge coverage, tenant isolation of export | qa | 5 | B4, B7 |
| B9 | DOCS: predicate spec doc (in-toto Statement v1 mapping) + verifier usage; mark v1.1 transparency layer as deferred | docs | 3 | B7 |
Deferred to follow-up PRD (founder-triggered, ADR-0012 strategy)
- Sigstore Fulcio sign + Rekor publish (public-tenant) + SCITT stub (private-tenant) — new dependency + new egress, founder-gated.
- Selective-disclosure Merkle scheme — patent carve, founder-gated.
- Reproducible bytecode + content-addressable artifact registry — patent carve, founder-gated.
- in-toto predicate-type registration, EU AI Act / SOC 2 regulator briefings — strategy, ADR-0012.
Cross-track dependency: B5 depends on A2 (the re-pin path produces the pinned guardrail hash + compiler version the receipt records). Otherwise Track A and Track B are independent and can run in parallel.
7. Open Items for Founder
- Predicate field set — confirm the 5 anchor fields (§4.1) match ADR-0012's Specialization-3 intent before we freeze the in-product schema (changing it post-persist is a migration).
- MVP boundary — confirm public-transparency (Sigstore/Rekor/SCITT) and the patent-carve pieces stay out of this milestone and into a follow-up PRD (§5).
- Open-vs-patent line — the open-spec publication and the two patent carves (compiler, Merkle) are strategy decisions bound by ADR-0012; this LLD does not pre-decide them.
args_discloseddefault — we default to hash-only (no args bytes in the receipt) for tenant-data safety; confirm that default for the public-transparency case.