Skip to main content

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:

  1. Mid-session guardrail re-pin. Today GuardrailHashes is pinned once at session init (internal/runtime/session/initializer.go:165-169). The PromptIntegrityVerifier (internal/runtime/security/integrity.go) calls Context Engine ValidatePrompt with those pinned hashes on every SendMessage. If an admin tightens a guardrail mid-run, the Context Engine's current layer hashes no longer match the pinned snapshot, ValidatePrompt returns valid=false, and the verifier emits SeverityBlockhard-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.

  2. 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)

ConcernFile:lineToday
Guardrail hash pinninginternal/runtime/session/initializer.go:165-169Pinned once at session init from immutable layers L1-L4
Integrity verify (per-call)internal/runtime/security/integrity.go:61-112Calls ValidatePrompt with snap.GetGuardrailHashes(); valid=falseSeverityBlock
Pre-dispatch pipelineinternal/runtime/security/pipeline.go:271-295Runs integrity per SendMessage; fail-OPEN on RPC error, fail-CLOSED on valid=false
Embedded PDPinternal/governance/engine.go (in-process lib, engine.Check())Read-through-per-call; 4-layer cascade via cascade.go
Cascade resolveinternal/governance/cascade.go (CascadePolicySet)Single fetch, tightest-first walk, sticky-deny
Audit hash-chaininternal/runtime/audit/{writer,chain,hashchain,verifier}.go; migration 036_audit_log_hash_chainrow_hash = SHA256(prev_hash ‖ JCS(content)), session-scoped, epoch semantics, flag audit.chain_enabled
Audit entryinternal/runtime/audit/writer.go Entry{}Has Detail, InputHash, OutputHash, ProvenanceChain; no policy/guardrail/evaluator hashes
ValidatePrompt RPCpkg/contextpb/.../context.pb.go ValidatePromptRequest{Scope, LayerHashes}{Valid, ViolationCode, ViolationDetail}Stateless compare against current layers
Compiled guardrail layersinternal/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)

FieldSource — already in-hand at decision time
policy_bundle_hashcascade.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_hashDecision.PolicyID + the matched Policy row already walked in the cascade. Hash its canonical form.
cascade_layerDecision.DeniedBy / tightest-matched layer from Decision.CascadeTrace.
member_state_hashDeterministic 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_hashJCS 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 a Decision; we extend the Decision-to-audit mapping to also populate a new ReceiptPredicate on the audit.Entry.
  • Storage: extend agent_audit_log (migration 111, additive nullable columns) with policy_bundle_hash, matched_policy_hash, member_state_hash, guardrail_compiler_version, evaluator_version, and a receipt_predicate JSONB. The existing row_hash chain (migration 036) already covers these new columns because row_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_log rows 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):

  1. Fetch the audit row by action_id; recompute row_hash from prev_hash ‖ JCS(content); assert it matches the stored chain → tamper-evidence.
  2. Fetch policy bundle bytes by policy_bundle_hash (from the content-addressed policy store, §5); recompute the cascade against member_state_hash + args_hash; assert the verdict matches → decision replay.
  3. If a guardrail block is present, recompile the sentence with the recorded guardrail_compiler_version; assert compiled_predicate_hash matches → guardrail replay.
  4. (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

CapabilityMVP (this milestone)Later (v1.1+, separate PRD)
Re-pinFull: 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 predicateAll 5 anchor fields persisted on agent_audit_log (bundle hash, matched hash, member-state hash, compiler version, evaluator version)
Tamper-evidenceFree via existing row_hash chain (036) over the new columns
Content-addressed policy storeMVP: hash + persist the resolved bundle bytes keyed by policy_bundle_hash so the verifier can re-fetchSelective-disclosure Merkle scheme (verify matched rule without disclosing the full bundle — patent carve)
Reference verifierupsquad verify-receipt steps 1-3 (chain + replay + guardrail recompile), offline against our storesStep 4: Sigstore signature + Rekor/SCITT inclusion-proof verification
Public transparencyNot in MVPSigstore Fulcio sign at emit, Rekor for public-tenant, SCITT stub for private-tenant; transparency block populated
in-toto / SLSA / SCITT registrationPredicate 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 reproducibilityStamp guardrail_compiler_version constant; record compiled_predicate_hashReproducible 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)

#TitleLabelsPtsDeps
A1BACKEND: ResolveGuardrailLayers proto + Context Engine impl (returns L1-L4 with monotonic version)backend3
A2BACKEND: turn-boundary re-pin in orchestrator (forward-adopt, backward/same-version drift-block, guardrail_repin audit)backend5A1
A3BACKEND: soft re-pull fallback + integrity-verifier version-aware violation codes (distinguish tamper vs. tightening)backend3A1, A2
A4QA: re-pin tests — forward-adopt, backward-block, same-version-tamper-block, RPC-down soft fallback, isolationqa3A2, A3

Track B — Decision receipts (larger; the public artefact)

#TitleLabelsPtsDeps
B1BACKEND: receipt predicate struct + RFC 8785 (JCS) canonicalisation helpers (bundle, matched-policy, member-state, args)backend5
B2BACKEND: compute hashes inside engine.Check() from the already-loaded CascadePolicySet (no extra fetch); populate Decision.ReceiptPredicatebackend5B1
B3BACKEND: migration 111 — additive nullable receipt columns on agent_audit_log; confirm row_hash chain covers thembackend3
B4BACKEND: emit receipt at PDP pre-tool/pre-handoff hook (per-call + per-A2A-edge); wire to audit.Entrybackend5B2, B3
B5BACKEND: guardrail compiler-version stamp + compiled_predicate_hash (feeds receipt guardrail block; ties to A2)backend3A2
B6BACKEND: content-addressed policy-bundle store (persist resolved bytes keyed by policy_bundle_hash for verifier re-fetch)backend5B2
B7BACKEND: upsquad verify-receipt reference CLI (steps 1-3: chain recompute + decision replay + guardrail recompile)backend8B4, B6
B8QA: receipt tests — field population per verdict, replay equivalence, chain tamper-evidence over new columns, A2A-edge coverage, tenant isolation of exportqa5B4, B7
B9DOCS: predicate spec doc (in-toto Statement v1 mapping) + verifier usage; mark v1.1 transparency layer as deferreddocs3B7

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

  1. 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).
  2. 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).
  3. 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.
  4. args_disclosed default — we default to hash-only (no args bytes in the receipt) for tenant-data safety; confirm that default for the public-transparency case.