ADR-016: Operator verdict modal + tree-writer as primary
Status
Accepted (2026-05-20). Tier 2 of the prod-readiness roadmap.
Gate-1 adversarial review of this ADR produced 22 findings (6
critical / 9 high / 7 medium); all remediated in-document.
Disposition log:
specs/v2/validation-impl-pass-tier2-remediation.md.
Amends:
- ADR-015 Part C (
015-pass-persistence-and-jsonl-source-of-truth.md) — swaps which writer is the AttestationStore primary writer. Tier 1 wired the FLAT writer (.ghyll/attestations.jsonl). Tier 2 swaps the tree writer (.ghyll/attestations/v<N>/...) into the primary slot; the flat writer becomes an Observer (aggregate audit tail).
Context
The analyst pass on Tier 2 (see
specs/architecture/components/operator-attestation.md)
captured three operator decisions:
- The verdict UI is an interactive modal in the chat REPL —
blocks the turn until the operator answers, with a
skipoption for deferral. - The per-pass JSONL is the load-bearing audit surface
(
attestations/v<N>/<context>/stratum-<S>/<role-pair>/<pass-id>.jsonl). The flat aggregate stays as a tail. - The hint payload is synthesized from clause metadata (minimal). Producer-side richer hints are deferred to Tier 3.
The existing runner.AttestationTreeWriter (phase 9) writes a
tree but the path is keyed on attestation_id (per-clause file)
instead of pass_id (per-pass file), and contextFromArrow /
stratumFromArrow return the placeholder "default". Tier 2
fixes both.
The existing AttestationRecord carries attestation_id,
kind, arrow_id, clause_id, op_id, attested_by_role,
source_role, target_role, verdict, reason, timestamp,
grid_version. Tier 2 adds pass_id, unit, unit_payload_json,
hint_json.
Decision
Part A: Schema extension + migration
Per gate-1 F-2 / F-3 / F-6 / F-10 / F-25, the migration adds SEVEN columns (not four) and wraps the ALTERs in a single transaction:
BEGIN;
ALTER TABLE attestations ADD COLUMN pass_id TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN context TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN stratum TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN adversary_role TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN unit TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN unit_payload_json TEXT NOT NULL DEFAULT '';
ALTER TABLE attestations ADD COLUMN hint_json TEXT NOT NULL DEFAULT '{}';
COMMIT;
pass_id: which pass produced the verdict. Pre-Tier-2 rows have''; verifier-tooling tolerates the empty string; Tier 2 Record path REJECTS empty PassID withErrAttestationPassIDEmpty(gate-1 F-6).context,stratum: stamped by the dispatcher at record- construction time soEncodeAttestationPathdoesn’t need a Grid lookup (gate-1 F-2).adversary_role: empty for normal records; populated byrunner/adversarial.gowhen a verdict is captured during an adversary-phase pass (gate-1 F-3). Self-cert check extends to forbidadversary_roleequal tosource_roleortarget_role.unit: one ofconfirm/record-locations-inspected/write-residue-note. Empty for pre-Tier-2 rows.unit_payload_json: JSON object whose shape depends onunit.'{}'forconfirm;{"inspected": [...]}forrecord-locations-inspected;{"residue": "..."}forwrite-residue-note.hint_json: the dispatcher-synthesized hint shown to the operator. Default'{}'(NOT empty string) so the verifier’sjson.Unmarshalparses pre-Tier-2 rows cleanly (gate-1 F-25). Shape:{"arrow_id": "", "clause_id": "", "concept": "", "attestation_ref": ""}. Tier 3 addslocations,basis,residue.
These columns are baked into the fresh CREATE TABLE in
engine/store.go — no migration path is preserved. (The
original ADR specified an ensureUnitColumns ALTER-based
migration; this was dropped pre-prod when the v2→v5 chain was
collapsed into a single baseline schema.)
Part B: Tree writer becomes primary
AttestationTreeWriter gains:
- A
PrimaryWriter() func(AttestationRecord) errormethod that mirrorsAttestationJSONLWriter.PrimaryWriter(). No*Gridargument (gate-1 F-2 / F-19). Inline marshal + write- fsync; returns the error inline so
AttestationStore.Recordfails closed if the tree write fails.
- fsync; returns the error inline so
- The file is opened
O_RDWR | O_APPEND(gate-1 F-11) so the writer can ReadAt forTruncateTrailingPartial. - A new
TruncateTrailingPartialAll(root) errormethod that walks every<root>/v*/<ctx>/stratum-*/<role-pair>/<pass-id>.jsonland calls TruncateTrailingPartial on each. Called by session.openEngineWithOptions after LoadFromTree returns truncated=true (gate-1 F-11). - A new
Observer()method that mirrors the flat writer’s flow (already exists; preserved for the rare fallback). - Path encoding moves to a new pure function (gate-1 F-2):
EncodeAttestationPath(rec AttestationRecord) (path string, truncated bool, err error). The helper:- Reads
pass_id(NOTattestation_id) for the file name. - Reads
context,stratum,source_role,adversary_role,target_roledirectly fromrec. No Grid lookup is required — the dispatcher stamped them at record-construction time. - Returns the tree-rooted relative path
v<N>/<context>/stratum-<S>/<role-pair>/<pass-id>.jsonl. - Init arrows special-case (gate-1 F-18): role-pair =
literal
"init", context = literal"init", stratum = literal"init". No__separator. - Three-role chain: when
rec.AdversaryRole != "", role-pair ="{source}__{adversary}__{target}"(gate-1 F-3). Self-cert: AdversaryRole MUST NOT equal SourceRole or TargetRole and MUST NOT contain__. - Empty PassID rejects with
ErrAttestationPassIDEmpty(gate-1 F-6). - Empty segment (sanitize produced empty string) also
triggers the byte-cap fallback to a
h-<sha256[:16]>name; truncated=true. - Per-component byte length capped at 255 (gate-1 F-17):
overflow returns
truncated=trueAND the PrimaryWriter appendspath-truncated:<segment>toAttestationRecord.ReasonAND publishesErrPathComponentTooLongvia the bus. The write proceeds with the hash-substituted segment so the verdict is not lost. - Step 7 formatting:
fmt.Sprintf("v%d", rec.GridVersion)(gate-1 F-26).
- Reads
session_engine.attachJournal swaps the primaryWriter:
// Before (Tier 1):
r.attestations.SetPrimaryWriter(r.jsonlWriter.PrimaryWriter())
r.attestations.Observe(r.treeWriter.Observer())
// After (Tier 2):
r.attestations.SetPrimaryWriter(r.treeWriter.PrimaryWriter())
r.attestations.Observe(r.jsonlWriter.Observer())
Boot-time loader also swaps (gate-1 F-1 / F-27): Tier 2’s
session.openEngineWithOptions calls
r.attestations.LoadFromTree(treeRoot, attCount > 0) INSTEAD
of LoadFromJSONL(flatPath, ...). The tree is the
authoritative source post-Tier 2. The flat file is
forward-only (Observer writes append; no Tier 2 path reads
from it). Tier 1’s LoadFromJSONL stays in place for
backward-compat tests but isn’t called from the production
session-open path.
LoadFromTree walks <root>/v*/<ctx>/stratum-*/<role-pair>/<pass-id>.jsonl
and unions every JSONL line into byID via recordReplay
(same idempotency contract as LoadFromJSONL). Truncation
behavior mirrors Tier 1: (loaded, truncated, err) return;
truncated=true triggers a TruncateTrailingPartialAll pass.
ghyll engine verify-attestations is extended to walk BOTH
the tree AND the flat aggregate; any divergence (a tree line
not in the flat, or vice versa) is reported with
ErrAttestationAggregateDivergence.
Part C: Verdict-unit schema + validation
New types:
// VerdictUnit names the shape of the operator's evidence.
type VerdictUnit string
const (
VerdictUnitConfirm VerdictUnit = "confirm"
VerdictUnitRecordLocationsInspected VerdictUnit = "record-locations-inspected"
VerdictUnitWriteResidueNote VerdictUnit = "write-residue-note"
)
// VerdictUnitPayload is the typed shape per-unit. JSON-marshaled
// into AttestationRecord.UnitPayloadJSON at write time.
type VerdictUnitPayload struct {
// Set when Unit == record-locations-inspected.
Inspected []string `json:"inspected,omitempty"`
// Set when Unit == write-residue-note.
Residue string `json:"residue,omitempty"`
}
// MaxResidueNoteBytes caps a write-residue-note payload at
// 16 KiB by default. Operator-configurable via the grid file's
// `residue-note-max-bytes` setting.
const DefaultMaxResidueNoteBytes = 16 * 1024
var (
ErrVerdictUnitInvalid = errors.New("verdict-unit-invalid")
ErrVerdictUnitMissingField = errors.New("verdict-unit-missing-field")
ErrVerdictResidueTooLong = errors.New("verdict-residue-too-long")
ErrVerdictInspectedEmpty = errors.New("verdict-inspected-empty")
)
// ValidateUnitPayload returns nil iff payload satisfies the
// unit's schema. Called at the AttestationStore.Record boundary
// BEFORE the primaryWriter fires.
func ValidateUnitPayload(unit VerdictUnit, p VerdictUnitPayload, maxResidueBytes int) error
AttestationStore.Record validates the unit before calling
the primary writer. A schema failure returns the typed error;
no JSONL line is written; no in-memory mutation happens.
Part D: Operator modal driver
New package: cmd/ghyll/modal (or inline in cmd/ghyll/session.go
— see architect note below). Exports:
// OperatorModalPrompt is the contract the chat REPL uses to
// present a verdict modal. Implementations: TermModal (tty
// interactive); StubModal (test injection); ScriptedModal
// (BDD replay from a fixture).
type OperatorModalPrompt interface {
// PresentVerdict blocks until the operator submits a verdict
// or returns ErrModalSkipped. The Hint argument carries the
// dispatcher-synthesized payload. Returns the verdict + unit
// + payload, ready to feed into AttestationStore.Record.
PresentVerdict(ctx context.Context, hint Hint) (VerdictSubmission, error)
// PresentEscalation blocks until the operator chooses option 1
// (accepted-risk + residue note) or option 2 (route-upstream +
// rationale). No default; no skip.
PresentEscalation(ctx context.Context, hint Hint) (EscalationChoice, error)
}
type Hint struct {
ArrowID string
ClauseID string
Concept string
AttestationRef string
}
type VerdictSubmission struct {
Verdict runner.AttestationVerdict
Unit runner.VerdictUnit
Payload runner.VerdictUnitPayload
}
type EscalationChoice struct {
Option int // 1 = accepted-risk; 2 = route-upstream
Residue string
}
var ErrModalSkipped = errors.New("modal-skipped")
The modal driver wires:
// In cmd/ghyll/session.go, post-attachJournal:
s.modalDriver = newModalDriver(s.modalPrompt, s.engine.AttestationStore(),
s.engine.Passes(), s.engine.Bus(), s.engine.IBTracker())
// modalDriver subscribes the bus.
s.engine.Bus().Subscribe(s.modalDriver.OnEvent)
modalDriver.OnEvent filters for OpEventAttestationRequested
and OpEventInsufficientBasisRoundsExceeded. On either, it
queues a modal-presentation request that the REPL turn loop
drains BEFORE the next model call.
Part E: REPL turn-loop integration
Session.Run (the chat loop) gets a new pre-turn check:
for {
line := s.readInput()
if handled := s.dispatchSlashCommand(line); handled.Handled {
// ... existing slash handling ...
continue
}
// NEW: drain pending modal prompts before the model call.
if err := s.modalDriver.DrainPending(ctx); err != nil {
s.output(fmt.Sprintf("⚠ modal error: %v", err))
}
// ... existing model invocation ...
}
DrainPending blocks until all queued modal-presentation
requests are answered (or the operator types skip for each).
Each Present* call records via AttestationStore.Record; the
tree writer’s PrimaryWriter persists; the chat loop continues.
Part F: Path encoding precision
EncodeAttestationPath algorithm (Part B above expands here):
input: AttestationRecord rec, Grid g (for arrow lookup)
output: path string, error
1. Resolve arrow def via g.Lookup(rec.ArrowID).
If not found → use rec.SourceRole / rec.TargetRole as
fallback (recovery path may run before grid replay
completes; defensive).
2. Determine role-pair string:
a. If arrow def has 3-role chain (adversary-augmented):
"{source}__{adversary}__{target}".
b. If init arrow: "init__{target_role}" (init has no
source role in the chain).
c. Otherwise: "{source}__{target}".
3. Determine context segment:
a. If init arrow: literal "init".
b. Else: arrow def's Context field (sanitized for
filesystem: replace any of [/\\:*?<>|] with "_").
4. Determine stratum segment:
"stratum-" + sanitized(arrow_def.Stratum).
5. Pass-id segment: sanitized(rec.PassID) + ".jsonl".
6. Per-component byte-length check (255-byte cap per ext4):
if any component exceeds 255 bytes, replace it with
"h-" + first 16 hex bytes of sha256(original). Emit
ErrPathComponentTooLong via the bus.
7. Return filepath.Join(treeRoot, "v"+gv, ctx, stratum,
rolePair, passFile).
Tier 3-augmented arrows (adversary phase added mid-pass) can change the role-pair between rounds. The path encoded at verdict-time captures the role chain AT VERDICT TIME; the tree may contain multiple files for the same logical “pass” if the chain mutated. Operator-tooling can join via the aggregate JSONL (which has all lines regardless of tree path).
Part G: hint synthesis (Tier 2 minimal)
runner.SynthesizeHint(clause, arrow_def) Hint:
func SynthesizeHint(c Clause, def ArrowDefinition) Hint {
return Hint{
ArrowID: def.ID,
ClauseID: c.ClauseID,
Concept: c.Concept,
AttestationRef: c.DepthTypeAttestationRef,
}
}
The dispatcher calls this when publishing
OpEventAttestationRequested and stores the resulting
JSON in the (eventual) attestation_requests table or
inlines it on the bus event. Decision (post-architect
review): inline on the event for Tier 2; promote to a
persisted table only if Tier 3’s session-ends-mid-attestation
flow needs it (Tier 1’s evaluation_runs.depth_type_attestation_ref
is enough for the basic recovery flow).
Consequences
Positive:
- 12 of 15 attestation.feature
@deferredscenarios become liftable. - The operator gets a real verdict UX, not a power-user slash command.
- The per-pass tree audit is structurally sound (matches the scenarios’ literal text).
- Unit-conditional validation closes the gate-1 F-1 finding about missing required fields.
- ADR-015 Part C’s “JSONL is source of truth” invariant is preserved — just shifted to the tree.
Negative:
- ADR-015 amended again (now ADR-016 amends Part C). Operators inspecting attestations should consult the tree as primary and the flat as aggregate.
- Schema migration runs at the v3→v4 boundary. Older binaries refuse to open v4 stores (verifySchemaVersion).
- The chat REPL gains a new blocking point. Operators who expected uninterruptible turn loops will be surprised.
- Test depth increases: every BDD scenario in F-1 through F-4 needs a ScriptedModal injection point.
- Path-encoding edge cases (255-byte cap, sanitization, three-role chains) require careful test coverage.
- Concurrent operator scenario stays deferred (Tier 3+).
Alternatives considered
Alt 1: Keep flat as primary, add a tree-rebuild CLI. Tier 2
would not swap PrimaryWriter; the flat continues as
source-of-truth; ghyll engine rebuild-tree materializes the
tree as needed for forensic review. Rejected — the scenarios
explicitly assert the per-pass tree is the primary write
surface (“a record is appended to … attestations/…”).
Alt 2: Add a separate AttestationStore-per-pass. One in-memory store per pass, each with its own primary writer. Rejected — multiplies the runner-layer state; the existing single AttestationStore with multi-PrimaryWriter chains doesn’t exist (PrimaryWriter is single-valued) but the tree writer’s path-encoding gives the per-pass-ness without state multiplication.
Alt 3: Defer the modal; ship the slash command only.
Operators type /attest <ref> pass --unit confirm. Rejected
— the F-3 / F-4 escalation flow with “operator must choose
between option 1 and option 2” doesn’t work via slash
command; you can’t make a slash command non-defaulted.
Modal is needed for the escalation prompt at minimum.
Implementation seam
engine/store.go: the 7 Tier 2 columns are part of the baselineCREATE TABLE attestations. (Pre-prod the v2→v5 ALTER chain was collapsed into a single fresh schema.)engine/records.go: new fields onAttestationRecord(in the engine variant — runner’srunner.AttestationRecordmirrors).runner/attestationstore.go:AttestationRecordgainsPassID,Unit,UnitPayload,HintJSON.Validate(rec)callsValidateUnitPayload.runner/attestation_tree.go:- File name is
<sanitized(rec.PassID)>.jsonl(was<rec.ID>.jsonl). contextFromArrow+stratumFromArrowuse the grid lookup; placeholder removed.- Add
PrimaryWriter()method. - Implement
EncodeAttestationPath.
- File name is
runner/dispatcher.go:SynthesizeHint(c, def)+ inline in thePublish(OpEventAttestationRequested)call.cmd/ghyll/session.go: subscribe modalDriver; newDrainPendingpre-turn step.cmd/ghyll/modal/(new package):OperatorModalPromptinterface +TermModalimpl +StubModalfor tests.tests/acceptance/: BDD step bindings useStubModal/ScriptedModalto drive verdicts.
References
specs/architecture/components/operator-attestation.md(analyst output)- ADR-010 (the original engine-table-is-source-of-truth, amended in Tier 1)
- ADR-015 (Tier 1 — pass persistence + JSONL flat as primary)
specs/features/attestation.feature(the 12 scenarios this enables)