Evidence ledger
An application that estimates or decides from evidence needs two things that pull against each other: the facts as they first arrived, and the answers that come later. One estimating application kept its original facts in a ledger that no step could change. A fact said that one detail was unknown; a person supplied it later; and the reviewing step kept asking the estimating step to correct the ledger, which it could not do. What fixed it was saying plainly, in each step's instructions, which component may change what.
This page shows that pattern: original facts written once, later answers as records of their own, the boundary stated in the intents, and enforced by rule sets and by the review's type.
Facts and resolutions
/// <summary>An original fact: written once, when the evidence arrives, and never changed.</summary>
public sealed record EvidenceFact(string Id, string Text, string Source);
/// <summary>A later answer to a fact: a record of its own, which names the fact it answers.</summary>
public sealed record FactResolution(string FactId, string Answer, string ResolvedBy);
/// <summary>What the estimator reads: the facts as they were recorded, and every resolution since.</summary>
public sealed record LedgerInput(IReadOnlyList<EvidenceFact> Facts, IReadOnlyList<FactResolution> Resolutions);
/// <summary>The estimate, the ids it rests on, and the facts that are still open.</summary>
public sealed record LedgerEstimate(double Hours, IReadOnlyList<string> Basis, IReadOnlyList<string> OpenFacts);
/// <summary>What a reviewer may ask for. No action changes a fact.</summary>
public enum ReviewAction { ReviseEstimate, AskForResolution }
/// <summary>One request of a review, and the fact it concerns.</summary>
public sealed record ReviewRequest(ReviewAction Action, string FactId, string Detail);
/// <summary>A review: accepted, or what should happen next.</summary>
public sealed record LedgerReview(bool Accepted, IReadOnlyList<ReviewRequest> Requests);
- An
EvidenceFactis written once, by your intake code, when the evidence arrives, and never updated. - A
FactResolutionis a later answer to one fact, with who gave it. It names the fact and never replaces it. - The estimator's input holds both, so the estimate can cite the original fact and its answer.
Say who may change what
// Each intent says what its component may change. Neither the estimator nor the reviewer changes the ledger.
public const string EstimateIntent =
"Estimate the labor hours for this job from the evidence. The evidence ledger is read-only: each fact keeps the " +
"text it was recorded with. A resolution answers a fact later: where a fact has one, use the resolution and cite " +
"both. List under openFacts each fact that is still unanswered. Never ask for a fact to be changed.";
public const string ReviewIntent =
"Review this estimate against the evidence and its resolutions. Neither the estimator nor you can change the " +
"ledger: for a fact that is still open, ask for a resolution; for an estimate that does not follow from the " +
"evidence, ask for a revised estimate.";
Each intent states the boundary. The estimator uses a resolution where a fact has one, and lists the facts that are
still open. The reviewer asks for a resolution or for a revised estimate, never for a change to a fact. A call to
TransformAsync changes nothing in your data: it returns a value. The words keep two steps from asking each other for
something neither can do.
Enforce it with rule sets
A component that runs commands through a target does so under a rule set. The feedback component records resolutions, and its set denies the ledger's table outright:
intentsharp: rules/1
set: feedback-data
checkpoint: data
target: sqlite
rules:
- id: ledger-is-original
subject: data.entities
deny: [Evidence]
message: The evidence ledger keeps each fact as it was recorded. Add a row to Resolutions that names the fact instead.
- id: resolutions-only
subject: data.entities
allow: [Resolutions]
message: Write only to Resolutions.
- id: read-and-write
subject: data.operations
allow: [read, write]
message: Read or write rows; nothing else.
settings:
data.allowWrites: true
- A deny always wins over an allow, so an edit that widened the allow list, to
"@described"for instance, would still leaveEvidenceout. data.allowWritesis true for this set only.
// The feedback component writes resolutions. Its rule set denies the ledger's table outright.
var runtime = new TargetRuntime(client, target, new TargetRuntimeOptions { GuardrailSets = ["feedback-data"] });
CommandOutcome recorded = await runtime.RunAsync("Record this answer to a fact of the evidence ledger.", new { note });
foreach (var issue in recorded.Planning.Issues)
Console.WriteLine($"Attempt {issue.Attempt}: {issue.Message}"); // a plan that touched the ledger, refused
if (recorded.Executed)
Console.WriteLine($"{recorded.Plan.Command} ({recorded.Result!.Affected} row)");
The estimator's side reads both tables under a set that allows no write, with plans written in code, which are checked the same way and make no model call:
intentsharp: rules/1
set: estimate-read
checkpoint: data
target: sqlite
rules:
- id: ledger-tables
subject: data.entities
allow: [Evidence, Resolutions]
message: Read only the evidence ledger and its resolutions.
- id: read-only
subject: data.operations
allow: [read]
message: The estimator reads; it changes nothing.
// The estimator's side reads under a read-only set. A plan written in code is checked the same way, with no model call.
var runtime = new TargetRuntime(client, target, new TargetRuntimeOptions { GuardrailSets = ["estimate-read"] });
static CommandPlan Sql(string statement) => new() { Command = JsonValue.Create(statement) };
CommandOutcome facts = await runtime.ExecuteAsync(Sql("SELECT Id, Text, Source FROM Evidence ORDER BY Id"));
CommandOutcome resolutions = await runtime.ExecuteAsync(Sql("SELECT FactId, Answer, ResolvedBy FROM Resolutions ORDER BY Id"));
var ledger = new LedgerInput(facts.As<EvidenceFact>(), resolutions.As<FactResolution>());
The manifest, intentsharp.guardrails.json, lists both files (Guardrails).
Give the reviewer no way to ask for a change
The estimate is one call:
AIResult<LedgerEstimate> estimate = await ledger.AIAsync<LedgerEstimate>(client, EstimateIntent);
The review's result type lists the actions a reviewer may ask for, and none of them changes a fact. A reply that asks for anything else fails the type check, and goes back to the model with the finding:
// A review can ask only for a ReviewAction. A reply that asks for anything else, such as a change to the ledger,
// fails the type check and is retried with the finding.
AIResult<LedgerReview> review = await new { ledger, estimate }.AIAsync<LedgerReview>(client, ReviewIntent);
A run on scripted replies
The feedback component's first plan updates the ledger and is refused; its next plan adds a resolution, and the fact keeps its text. The estimate cites the fact and its resolution. The review's first reply asks for a change to the ledger, which no action allows, and its next one accepts the estimate:
The feedback component records an answer to E2, under feedback-data:
Attempt 1: The evidence ledger keeps each fact as it was recorded. Add a row to Resolutions that names the fact instead.
Attempt 1: Write only to Resolutions.
Ran: INSERT INTO Resolutions (FactId, Answer, ResolvedBy) VALUES (@fact, @answer, @by) (1 row)
E2 in the ledger still reads: Who isolates the feeder panel: unknown.
The estimator reads the ledger under estimate-read: 3 facts, 1 resolution
E1 (scope-letter.txt): Replace the 14 light fixtures in the east corridor.
E2 (site-visit.txt): Who isolates the feeder panel: unknown.
E3 (scope-letter.txt): Work only at night, from 22:00 to 06:00.
Resolution of E2 (site manager): Building facilities isolates the feeder panel before the work starts.
A write under estimate-read:
estimate-read/read-only: The estimator reads; it changes nothing.
The estimate: 9 hours
E1: 14 fixtures at 30 minutes, 7 hours.
E3: nights only, so two nights with set-up, 2 hours.
E2 and its resolution: building facilities isolates the panel, so no electrician hours.
Open facts: none
The review:
[Verification] attempt 1: The JSON did not match the expected type LedgerReview
where LedgerReview { bool Accepted; IReadOnlyList<ReviewRequest> Requests }
where ReviewRequest { ReviewAction enum [ReviseEstimate|AskForResolution] Action; string FactId; string Detail }: The JSON value could not be converted to QuickStart.Guides.ReviewRequest. Path: $.requests[0].action | LineNumber: 0 | BytePositionInLine: 55.
Accepted: true, after 2 attempts
The replies are scripted. A pass means the answer met the checks, not that the estimate is right.
Limits
- The rule sets check the commands a target runs. Your intake code writes the ledger over its own connection, and the
database's permissions keep anything else from changing it: give the feedback component an account that cannot write
Evidence. - IntentSharp does not learn from resolutions or reviews. A resolution changes what the next call is told, because your code passes it in the input.
- A type check refuses a value the type cannot hold. Free text, such as
Detail, can still ask for anything; read it as a suggestion, and act only on the action.