Portable, evidence-backed experience memory for .NET agents.
AgentExperience.NET captures what an AI agent actually tried, verifies whether it worked, and turns the result into an auditable lesson that future runs can reuse safely. It sits between Microsoft Agent Framework (MAF) execution and durable storage, without replacing either.
Status: early development. Epic 1 (capture and explain agent experience) is implemented and tested, and so is Epic 2 (reuse relevant experience): a completed run can now be finalized into a durable Experience Record in PostgreSQL in one call, moved through its lifecycle with atomic, audited commits, indexed as an embedding after the fact, retrieved by task text and by meaning with bounded, explainable ranking, and injected back into a later MAF invocation as a labeled, bounded Historical Reference. Governance is planned (see Roadmap). Nothing is published to NuGet yet, and APIs may change.
Conversation history and fact memory don't answer the questions that matter when an agent retries similar work:
- Which approaches failed, and which succeeded?
- How was success verified, not just claimed?
- In which environment does the lesson apply?
- Is it safe for another agent to reuse?
AgentExperience.NET records observable evidence (tool calls, results, errors, verification checks) and never stores hidden chain-of-thought.
| Capability | Where |
|---|---|
| Domain contracts: experience runs, attempts, evidence, outcomes, reflections, scope, environment | AgentExperience.Abstractions |
| Sanitization before storage: per-kind allowlists, secret redaction, fail-closed rejection | AgentExperience.Core |
| Thread-safe in-memory run capture with idempotent appends and size limits | AgentExperience.Core |
| Deterministic task verification: exit codes, tests, workflow and human checks; host-closed rounds; no LLM | AgentExperience.Core |
| Auditable, template-based reflections traceable to evidence IDs | AgentExperience.Core |
| MAF adapter: captures ordinary, streaming, failed, and cancelled runs plus tool calls, without altering results | AgentExperience.MicrosoftAgentFramework |
| PostgreSQL Experience Record store: create, get, and scoped query; host authorization checked before database access; exact scope matching in SQL | AgentExperience.Storage.Postgres |
| Atomic audited lifecycle commits: the event and the record's projection in one transaction, idempotent by event ID, revision-checked, with bounded, cursored history | AgentExperience.Core, AgentExperience.Storage.Postgres |
| The full MVP transition table — reinforce, contest, stale, supersede, revoke — with supersession recording its replacement and refusing cycles, event logs made append-only by database triggers, and a record's embedding dropped when it leaves eligibility | AgentExperience.Core, AgentExperience.Storage.Postgres, AgentExperience.Storage.Postgres.Vectors |
Evidence-based reuse confidence: a versioned (1 + S) / (2 + S + F) heuristic Core computes from the record it read, with independence enforced by a unique index, a duplicate recorded but counted zero times, a contradiction contesting the record in the same transaction, and the confidence columns guarded by the database |
AgentExperience.Core, AgentExperience.Storage.Postgres |
Reuse feedback: one idempotent submission links a run to the records it saw, with an outcome, a measure and a trial label; exposure alone records benefit Unknown and moves nothing, an attribution that fails its evidence requirements degrades to Unknown rather than losing the exposure, and only a human assessment naming a host-established review or a comparative evaluator result carrying its own round-matched evidence becomes supporting or contradicting evidence |
AgentExperience.Core, AgentExperience.Storage.Postgres |
| Journaled schema migrations: embedded scripts applied once, one transaction per script, serialized across processes by an advisory lock | AgentExperience.Storage.Postgres |
One finalization call: evaluate, gate on authorization and the host's storage decision, reflect, create the record as a Candidate, commit the initial event that promotes it — replay-safe and structured at every stage |
AgentExperience.Core |
| Text retrieval of applicable experience: eligibility decided before ranking, every ranking component and effective weight exposed, bounded by a timeout that is never an exception | AgentExperience.Core, AgentExperience.Storage.Postgres |
| Embedding ingestion after the canonical commit: only the sanitized retrieval summary is embedded, writes are conditional on the live revision, and every provider failure leaves the record committed and retryable | AgentExperience.Core, AgentExperience.Storage.Postgres.Vectors |
| Hybrid retrieval: a bounded vector channel merged with the text one under the same eligibility, timeout, and ceiling, with an explicit, flagged text-only fallback whenever the vector channel cannot be trusted | AgentExperience.Core, AgentExperience.Storage.Postgres.Vectors |
| Historical Reference injection into MAF: a context provider that retrieves, re-checks eligibility immediately before injecting, asks the host's risk policy, and injects one delimited, labeled block within record and byte limits — never throwing into the invocation | AgentExperience.MicrosoftAgentFramework |
| Explicit sharing grants: an administrator the host names lets one named record be read by a sibling scope until it expires or is revoked; the grant and its audit event commit together, and reads honour it in SQL, never in application code. A grant's lifetime is bounded by a host-configured maximum, so there is no permanent grant | AgentExperience.Abstractions, AgentExperience.Storage.Postgres |
| An optional access log answering "who read our team's experience, and when": one append-only row per record a grant delivered, naming that grant and the revision disclosed, written outside the read's own statement and batched per search, best-effort or fail-closed as the host chooses, with an owner-scoped reader for the trail | AgentExperience.Abstractions, AgentExperience.Storage.Postgres |
| Dependency-injection registration for each package, so a host wires capture, finalization, storage, indexing, and retrieval without knowing concrete types. Injection is the one piece the host constructs itself, because the resolver and risk decision are per-host | AgentExperience.Core, AgentExperience.Storage.Postgres, AgentExperience.Storage.Postgres.Vectors |
AIAgent agent = chatClientAgent
.AsBuilder()
.UseExperienceCapture(captureService, new ExperienceCaptureOptions
{
ResolveRun = context => new ExperienceRunDescriptor(
TaskId: "triage-ticket",
Scope: hostScope), // established by the host, never taken from model output
OnCaptureFailure = failure => logger.LogWarning("Capture failed at {Stage}", failure.Stage),
})
.Build();
await agent.RunAsync("Triage ticket #4812", session);
// The run, its tool calls, and its sanitized outcome are now available from captureService.See the adapter README for options, supported agent types, and caveats. See the PostgreSQL store README for the trust boundary, the ExperienceSchemaMigrator.MigrateAsync startup call, and data semantics.
A captured run becomes a durable, reusable Experience Record through one Core call.
ExperienceFinalizationService.FinalizeAsync runs six stages in order — load the captured snapshot, evaluate it,
check authorization and the host's storage decision, reflect on it, create the record, commit its initial lifecycle
event — and stops at the first stage that ends the call, always returning a structured result rather than throwing.
The two gates precede reflection on purpose: the reflector is the seam a host would plug a model into, so a run that
is about to be refused is never handed to it.
using AgentExperience.Core.Finalization;
using AgentExperience.Core.Verification;
var result = await finalization.FinalizeAsync(
new FinalizeExperienceRequest(
RunId: runId,
Authorization: authorization, // host-established; the run's scope must lie inside it
ClosedRound: new ClosedVerificationRound(roundId, "rev-7"),
RequiredChecks: [new RequiredCheck("unit-tests-pass", ExpectedKind: "TestResult")],
Evidence: evidence, // finalization filters and aggregates it itself
CurrentArtifactRevision: "rev-7",
StorageDecision: StorageDecision.Permit, // or StorageDecision.Deny("retention policy")
FinalizedAt: DateTimeOffset.UtcNow),
cancellationToken);
if (result.IsDurable)
{
logger.LogInformation("Experience {Id} is {Status} at revision {Revision}",
result.ExperienceId, result.Status, result.Revision);
}
else
{
logger.LogWarning("Finalization ended at {Stage}: {Outcome} — {Reason}",
result.Stage, result.Outcome, result.Failure?.Reason);
}| Outcome | When | What was written |
|---|---|---|
Validated |
Verified, reflection succeeded, storage permitted | The record (reuse confidence 2/3, one supporting validation, no contradictions), created as Candidate, plus the initial event that moved it to Validated |
Quarantined |
Storage permitted, but verification did not pass or the reflector threw | The record, with no reflection, created as Candidate, plus the initial event that moved it to Quarantined. Failure names the stage that decided it |
AlreadyFinalized |
This run's record already exists and is already confirmed | Nothing. The result reports the stored record, status, and revision. (A record left unconfirmed by an earlier call is resumed instead: the retry commits its initial event and returns Validated/Quarantined.) |
StorageDenied |
The host's StorageDecision denied |
Nothing at all, and no record ID is issued |
NotAuthorized |
The run's scope lies outside the authorization | Nothing; denied before any store call |
RunNotFound / RunNotFinished |
No such captured run, or it has no execution status | Nothing |
Failed |
A stage failed (for example the database was unavailable) | Never reported as durable. Any record already created stays a Candidate, which is never reusable, and the captured run stays available for a retry |
Three properties make retrying safe. The record is created as a Candidate and its initial lifecycle event
performs the real transition, so a commit that never lands leaves nothing reusable behind. The record ID, the
reflection ID, and the initial event ID are all derived from the run ID, so a second call cannot create a second
record or a second initial confirmation. And the initial event's fields are a pure function of the stored record, so
a retry re-derives exactly the event the store already deduplicates on.
Finalization never sanitizes — capture already rejected anything unsafe — and never decides storage or risk policy on
the host's behalf: StorageDecision travels in the request and Core simply obeys it.
What "already rejected" means. Sanitization is the first gate, and it is fail-closed at capture time rather than
at storage time. When content cannot be sanitized, AppendAttemptAsync returns
AppendAttemptOutcome.SanitizationRejected and the sanitizer's own Reason, the attempt is not recorded, the run
stays open, and nothing is stored anywhere — there is no database involved, so there is no partial write and no
persisted denial record to reconcile later. The host is told the decision and why, and can correct and resubmit the
same attempt ID; the rejected ID is not tracked, so a corrected resubmission succeeds. Unsafe content therefore never
reaches an Experience Record, and never becomes something a grant could later share.
If an indexing hook is registered, one more thing happens after those six stages: the committed record is embedded and its vector stored. That step is outside the canonical write and can never change the outcome above — see Indexing experience for semantic reuse.
Finalization is only a record's first transition. After it, ExperienceLifecycleService is the only way a stored
record's status changes, and it accepts exactly this table:
| From | To | What it means |
|---|---|---|
Candidate |
Validated, Quarantined |
Finalization's own two outcomes |
Validated |
Reinforced |
Reuse was observed to succeed again — once; Reinforced → Reinforced is refused |
Validated, Reinforced |
Contested |
Later evidence contradicts the lesson. Exits only to Revoked |
Validated, Reinforced |
Stale |
The lesson is no longer current. Exits only to Revoked |
Validated, Reinforced |
Superseded |
A better record replaces it — and names which. Exits only to Revoked |
anything except Revoked |
Revoked |
Withdrawn by an authorized action. Terminal |
Everything else is TransitionNotAllowed, refused by Core before the store is called. That includes an event whose
prior and current status are the same: it would consume a revision and sit in the audit trail claiming a transition
that did not happen. It also includes a first event — one with no prior status — that records anything but
Candidate: a null prior status is how a record's creation is logged, never a way to move a record without saying
what it moved from.
Three consequences are worth stating outright rather than leaving to be discovered:
- Quarantine is now a capture-time decision only. Earlier versions accepted
Validated → Quarantined(andContested/Stale/Superseded/Reinforced → Quarantined). Those are refused now, at runtime, with no compile-time signal — the enum and the request type are unchanged. A host that quarantined a live record mustRevokeit instead, or contest it. - A record can be reinforced once.
Reinforced → Reinforcedrecords no transition and is refused, so this table cannot express repeated reinforcement. Evidence can:ApplyEvidenceAsyncmoves the counters without moving the status, which is the counter-that-moves-without-a-status-change answer to this limit rather than a carve-out in the table. ContestedandStaleare one-way. Nothing resolves a contest or refreshes a stale record back into eligibility in this version; both exit only toRevoked.
Port changes in this version. Nothing is published to NuGet yet, but anyone implementing the ports out of tree
has four breaks to absorb: IExperienceRecordStore gained CheckSupersessionAsync;
IExperienceRecordStore.GetHistoryAsync now takes an ExperienceRecordHistoryQuery and returns
StoredLifecycleEvents rather than bare LifecycleEvents (GetFirstHistoryPageAsync is the convenience for the
old four-argument shape); IExperienceEmbeddingIndex gained RemoveAsync; and ExperienceStoreOutcome gained
ReplacementNotAllowed, which a commit can now return. All four fail at compile time.
Evidence-based confidence adds three more, and none of them fails at compile time, so read them rather than trusting
the build: LifecycleEvent gained an optional Confidence, StoredLifecycleEvent an optional Actor, and
ExperienceLifecycleCommitResult an optional AppliedConfidence. An out-of-tree store still compiles and still
commits — it will simply drop a confidence payload on the floor while reporting Committed, which is a silently
wrong answer rather than a failed one. A store that means to support
ApplyEvidenceAsync has to persist the payload, enforce the independence key,
and report what it stored.
Only Validated and Reinforced are eligible. A record in any other status is never retrieved, never injected,
and never indexed — so contesting, staling, superseding, or revoking a record takes it out of reuse immediately,
through both channels, without deleting anything.
var result = await lifecycle.CommitAsync(
hostAuthorization,
new CommitLifecycleTransitionRequest(
EventId: Guid.NewGuid(), // the idempotency key; reuse it verbatim on a retry
ExperienceId: supersededId,
Scope: recordScope,
PriorStatus: ExperienceStatus.Validated,
CurrentStatus: ExperienceStatus.Superseded,
Reason: "replaced by the parallel-warmup lesson",
Producer: "governance-review/1.0",
OccurredAt: DateTimeOffset.UtcNow,
ExpectedRevision: stored.Revision,
ReplacementExperienceId: replacementId),
cancellationToken);Supersession names a replacement. A move to Superseded must carry ReplacementExperienceId, and every other
move must not. The replacement has to be a different record, in the record's exact scope, currently eligible, and
not one this record already replaces directly or transitively. The last of those is a walk over the stored
replacement chain, done in SQL in one round trip, so a cycle is refused (ReplacementNotAllowed) with nothing
written. A replacement in another scope is reported exactly like one that does not exist, so a cross-scope attempt
reveals nothing. The replacement ID is stored on the event itself, which is what makes the chain auditable.
Leaving eligibility drops the embedding — as hygiene, not as a boundary. When an ExperienceIndexingService is
wired into the lifecycle service, a commit that moves a record out of Validated/Reinforced removes its stored
vector afterwards, outside the transaction and on its own budget. What that buys is storage and index maintenance
cost, not correctness: a vector search joins the canonical record and filters on its status, so a surviving vector is
already unreachable the moment the transition commits. That is why it is reported on result.Deindexing and can
never fail the transition.
Nothing retries it. ReindexAsync lists only records a search could return and never removes anything, so there is
no sweep — a Deindexing outcome other than Removed or NotIndexed is a work item for the host: record the
experience ID and scope, and call ExperienceIndexingService.RemoveAsync again later. That includes Denied, which
reports IsRetryable: false because repeating the same call changes nothing; it needs a different authorization.
Reading the trail. IExperienceRecordStore.GetHistoryAsync returns one bounded page of a record's events,
oldest first, plus the record's current revision — from a single snapshot, so the two can never disagree. Each
stored event carries the store's own RecordedAt (the database's clock, not the caller's) and the AppliedRevision
it produced. Page with the keyset cursor:
long? cursor = null;
do
{
var page = await store.GetHistoryAsync(
hostAuthorization,
new ExperienceRecordHistoryQuery(recordScope, experienceId, Limit: 100, StartAfterRevision: cursor),
cancellationToken);
if (page.Outcome != ExperienceStoreOutcome.Found)
{
// NotFound, Denied or Invalid. Never treat one as an empty history: they mean the record is not
// readable here, not that it has no trail.
throw new InvalidOperationException($"History unavailable: {page.Outcome}.");
}
foreach (var stored in page.Events)
{
Console.WriteLine($"r{stored.AppliedRevision} {stored.Event.PriorStatus} -> {stored.Event.CurrentStatus}");
}
cursor = page.NextStartAfterRevision; // null once the page came back empty
}
while (cursor is not null);A record whose cursor has walked past its last event still reports Found with its revision and an empty page, so
"nothing left to show" stays distinguishable from NotFound. GetFirstHistoryPageAsync(authorization, scope, id, ct)
is the one-line convenience for the common case, and is named for what it does: it returns the first page only, and
a record with a longer trail has more.
Append-only is enforced by the database, not by convention. Migration 0006 installs triggers that reject every
way a stored event could stop being what it was:
| Attempt | What stops it |
|---|---|
UPDATE or DELETE on lifecycle_events / experience_grant_events |
row-level BEFORE UPDATE OR DELETE triggers |
TRUNCATE on either log, or on experience_grants |
statement-level BEFORE TRUNCATE triggers — TRUNCATE does not fire row triggers at all, so a row-level guard alone would let it erase the whole log with no error |
Clearing a grant's revoked_at, rewording its revocation_reason, extending its expires_at |
BEFORE UPDATE trigger on experience_grants |
| Deleting a revoked grant and inserting it again unrevoked | BEFORE DELETE trigger refusing any grant that has audit events |
| Re-pointing a live grant at another record or recipient | the same BEFORE UPDATE trigger, which pins the grant's identity and audit columns |
Winding a record's revision back, or moving its status without the revision its event produced |
BEFORE UPDATE trigger on experience_records — an immutable log beside a freely rewritable projection proves nothing |
A tamperer gets SQLSTATE 42501. Be precise about what that buys:
- It binds ordinary writes from any role, superusers included, as long as the triggers are enabled. They are
created
ENABLE ALWAYS, so they also fire undersession_replication_role = 'replica'— the mode logical replication appliers and several restore and ETL tools run in, and the mode in which an ordinary trigger is skipped silently. - It does not bind anyone who can
ALTER TABLEthese tables: a superuser, or the tables' owner, which the application role is because it created them. An owner canDISABLE TRIGGER,DROP TRIGGER, or drop a constraint and then write freely. Row-level security and column-privilegeREVOKEare no stronger — neither binds an owner. - It says nothing about backups, about a restore that recreates the tables without
0006, or about filesystem access to the data directory.
So it is a guard against a bug, a careless script, a compromised application path, or a replication apply that would otherwise rewrite history — not against an administrator who has decided to tamper. A deployment that needs tamper-evidence beyond this should ship the log off-box, or own these tables with a role the application does not have.
Because nothing can delete, purging is an explicit operator action. The logs carry free-text reason and
producer that a host may have filled with personal data, and roadmap story 4.5 ("delete and expire library-owned
data") has not landed. Until it does, the tables' owner purges in one transaction — disable the trigger, delete
narrowly, re-enable it — as documented in 0006's own header, and reconciles experience_records afterwards,
because deleting an event does not move the projection.
Upgrading an existing database. 0006 adds every CHECK as NOT VALID, so it does not scan existing rows and
cannot abort on a pre-0006 Superseded event that has no replacement — one the public port accepted, because the
store never applied Core's table. New and updated rows are checked from that moment on. The script's header carries
the reconciliation query and the VALIDATE CONSTRAINT statements to run once it comes back empty.
Finalization stamps a record at 2/3 and stops. ExperienceLifecycleService.ApplyEvidenceAsync is how that number
moves afterwards: submit what happened when the lesson was reused, and the evidence, the counters, the score, any
status change, and the audit entry are committed in one transaction.
var result = await lifecycle.ApplyEvidenceAsync(
hostAuthorization,
new ApplyConfidenceEvidenceRequest(
EventId: Guid.NewGuid(), // the commit's idempotency key
ExperienceId: experienceId,
Scope: recordScope,
EvidenceId: Guid.NewGuid(), // the evidence's own; reuse it verbatim on a retry
Kind: ConfidenceEvidenceKind.Supporting, // or Contradicting
Source: ConfidenceEvidenceSource.Machine, // or Human
RunId: runId, // the run the *reuse* happened in, not the record's source run
VerificationRoundId: roundId, // machine evidence only
Reason: "the retry-after-lock lesson was applied and the checks passed",
Producer: "verification-aggregator/1.0.0",
OccurredAt: DateTimeOffset.UtcNow),
cancellationToken);
if (result.Outcome == ConfidenceUpdateOutcome.Applied)
{
logger.LogInformation(
"Experience {Id} is now {Confidence:F3} ({S} supporting, {F} contradicting){Counted}",
experienceId, result.ReuseConfidence, result.SupportingValidations, result.Contradictions,
result.Counted ? "" : " — already counted, recorded only");
}The score is (1 + S) / (2 + S + F). S counts independent accepted supporting validations, including the one
the record was finalized with; F counts independent accepted contradictions. So a fresh validated record is
2/3, a first independent confirmation takes it to 3/4, and a contradiction after that takes it to 3/5.
It is a heuristic, not a probability. Laplace's rule of succession is a monotone, bounded summary of how often
reuse held up — useful for ranking and for a floor. It is not calibrated against anything, and nothing here claims
it is the probability that the next reuse will succeed. The rule is versioned: every accepted update records the
RuleVersion that produced it, so a later rule change stays auditable against scores computed under an earlier one.
It never changes eligibility. Confidence is independent of the completion score and of status; a number cannot
make an ineligible record eligible. What takes a record out of reuse is the status: a contradiction moves a
Validated or Reinforced record to Contested in the same transaction, and a record already Contested stays
there while its counters keep moving. Supporting evidence never changes a status by itself — which is how a record
keeps being reinforced through its counters even though Validated → Reinforced happens only once. (That is the
known limit the lifecycle table left open above; this is how it is expressed.)
Independence is keyed, and the database owns the key. Machine evidence counts once per (record, run, verification round); human evidence once per (record, reviewer, run). The key is a generated column in
confidence_evidence with a partial unique index over it, so no caller picks the key string: two submissions
describing the same observation collide however they are phrased.
The key's inputs are a host trust boundary — read this before wiring it up. Nothing stops a caller that invents
the key's inputs. There is no foreign key behind RunId or VerificationRoundId and nothing in the schema can
check that a run happened or that a round was closed, so a caller passing a fresh Guid for both on every
submission gets a fresh key every time and can drive the score as high as it likes. Establish them the way you
establish AuthorizationContext: from your own run bookkeeping and your own closed verification rounds, never
passed through from something an agent produced. ReviewerIdentity is the same boundary, and is the one the library
can enforce for you — it is taken from AuthorizationContext.PrincipalId and the request has no field for it,
because the number of distinct human reviewers is exactly what this rule protects. Principals are compared
ordinally, like every other identity here, and one with leading or trailing whitespace is refused rather than
trimmed. What the rule guarantees, stated exactly: a host that establishes these honestly cannot have its own
observations counted twice.
| Submission | Outcome |
|---|---|
| First for its independence key | Applied, Counted: true — counters and score move |
| Same run and round (or reviewer and run) under a new evidence ID | Applied, Counted: false — a ledger row is written and nothing else moves: no counters, no status, no revision, no UpdatedAt, and no lifecycle event |
| …and the record moved between the read and the commit | StaleRevision, StatusMismatch or NotFound, with nothing stored at all — a duplicate is still committed against the record it describes |
| Same evidence ID, identical content | Applied — the original outcome, reported again; nothing is written twice |
| Same evidence ID, different content | Conflict — nothing written |
| Two submissions computed from one revision | Exactly one Applied; the other StaleRevision with the revision to retry against |
Against a Candidate, Quarantined, Stale, Superseded, or Revoked record |
Ineligible — refused before anything is written |
A record cannot be created claiming evidence it does not have. CreateAsync refuses a record whose
ReuseConfidence is not the one its own counters explain — creation is the single moment the two arrive
independently, and after it every change goes through the guarded path above. A record created with no counters
may carry any confidence its host wants to seed it with; the first accepted evidence recomputes from those counters,
so a seeded number never survives contact with evidence.
Core owns the arithmetic; the adapter owns independence. Core reads the record, computes the new counters and the new score from what it read, and submits them with that revision, so the arithmetic and the concurrency guard are about the same version of the record. The adapter writes those numbers and derives none: what it decides is whether the independence key was free, and whether the revision still holds. Everything else is a fact it was given.
Why a duplicate must move nothing. The two obvious exceptions are the harmful ones. Refreshing UpdatedAt
would let one observation, replayed under fresh evidence IDs, keep a record permanently recent for ranking and
permanently un-expired — retrieval reads recency and expiry off that column. Writing the status would contest a
record on the strength of an observation the independence rule had just declared already counted, leaving an event
that says nothing moved beside a ledger with zero counted contradictions.
The counters are guarded like the rest of the projection. Migration 0007 extends the experience_records
trigger so reuse_confidence, supporting_validations, and contradictions move only together with the revision
of the lifecycle event that recorded the evidence for them — and only to the values that event recorded, so
UPDATE … SET reuse_confidence = 1, revision = revision + 1 is refused too. A direct UPDATE on any of them gets
SQLSTATE 42501, exactly as one on status or revision does — see the limits stated above for what that guard does and does not
bind. confidence_evidence is append-only for the same reason the event logs are: a row that could be edited or
removed would free an independence key, and the same observation could then be counted twice.
One ordering wart, stated rather than hidden. Core's eligibility gate runs on the record it read, before the
store is asked anything, so it takes precedence over the store's idempotency check: resubmitting evidence that was
already accepted, after the record has since been revoked or quarantined, reports Ineligible rather than
replaying Applied. Nothing is lost — the original update is durable and in the history — but reconcile retries
against the history rather than reading that as "it never landed".
History makes an update reconstructable. Each counted update's event carries the prior and new score, the
prior and new counters, the evidence ID, the rule version, and the Actor — the principal the commit ran under, recorded by the
store from the host's authorization and never from anything the caller put in the event. Read it through
GetHistoryAsync like any other transition; stored.Event.Confidence is null for the events that carried none.
An uncounted submission has no event, by construction — the ledger row is its audit trail, and listing that ledger
arrives with roadmap story 4.5 along with its retention path.
ExperienceLifecycleService.ApplyEvidenceAsync moves a score once you already know what reuse was worth.
ExperienceReuseFeedbackService.RecordAsync is how you find out — and it is deliberately hard to make it say yes.
var result = await feedback.RecordAsync(
hostAuthorization,
new ExperienceReuseFeedback(
FeedbackId: feedbackId, // the whole submission's idempotency key
RunId: runId, // the run the records were injected into
Scope: recordScope,
ExposedExperienceIds: injection.InjectedExperienceIds,
RunOutcome: TaskVerificationStatus.Verified,
Measure: new ReuseMeasure("tool-calls", 7), // a name you chose, and a number
ObservedAt: DateTimeOffset.UtcNow,
TrialLabel: "memory-enabled"), // optional, declared up front
cancellationToken);
// Outcome: Recorded. Benefit: Unknown. Nothing moved -- and that is the correct answer.Exposure is not attribution. That call records exactly what happened: a run saw these records and came out this
way. It does not record that the records helped, because nothing established that. Benefit is Unknown, no
confidence evidence is submitted, and no record's score, counters, or status changes. Almost every submission a real
host makes will end here, and it should.
A bare claim is never attribution. ClaimedBenefit is stored verbatim, so you can later compare what hosts
believed against what evidence established, and it is never acted on. Exactly two shapes move a score:
| Attribution | What it must carry | What it produces |
|---|---|---|
HumanReuseAssessment |
improvement or harm, the exposed records it is about, an auditable rationale, an AssessmentId naming the host-established review it came out of, optionally the verification round it was made against — and no reviewer field, because the reviewer is your AuthorizationContext.PrincipalId |
Human evidence, keyed human:{principal}:{run} |
ComparativeEvaluationResult |
the same records and rationale, plus the run it evaluated (which must be this run), its verification round, and the evidence it reached its conclusion from — each piece of which must name that same round | Machine evidence, keyed machine:{run}:{round} |
This library does not implement a comparative evaluator; it defines the contract and verifies the result it is given. Evidence from another round is not evidence about this comparison, and is refused.
Read this before you wire either one up — the library cannot check that any of it is true.
RunId,AssessmentId, andVerificationRoundIdare all host-established identifiers. Nothing here can verify that a run happened, that a round was closed, or that a human made an assessment and meant it. What the library actually guarantees is narrow: the reviewer is yourAuthorizationContext.PrincipalIdrather than anything on the submission, and one reviewer's opinion about one run counts once. Because the caller suppliesRunId, a host that lets agent output populate it hands the agent a fresh independence key on every call — and with it the ability to contest its own stored lessons over and over. The human shape is the weakest boundary in this library; requiring anAssessmentIdmakes a moved score traceable back to a review that exists, and that is all it does. Establish these from your own run and review bookkeeping, exactly as you establishAuthorizationContext, and never from anything an agent produced.
A failed attribution costs the attribution, not the exposure. An attribution that does not meet its evidence
requirements — no AssessmentId, no evidence behind a comparison, a blank rationale, a benefit of Unknown — is
dropped: the submission is still recorded, with benefit Unknown, no confidence submission, and a Reason naming
what was refused. Only a structurally incoherent submission is Invalid with nothing written: no feedback ID, no
records, an attribution naming a record the run never saw, or a comparative result about a different run. Losing
a true exposure to punish a bad attribution would throw away the one thing that was never in doubt.
Improvement supports, harm contradicts. An accepted attribution submits one piece of evidence per attributed
record, through ApplyEvidenceAsync and nothing else — so independence keying, duplicate suppression, the revision
guard, the eligibility gate, and the audit trail all apply exactly as described above. Attributed harm therefore
contests each record in the same transaction that records the evidence. Nothing is ever deleted: the record
stays, and its own history carries the reason.
| Situation | What happens |
|---|---|
| Records injected, no attribution | Exposure stored, benefit Unknown, nothing moves |
| Caller claims improvement with no evidence | Same — the claim is recorded, not acted on |
| Authorized human assessment | Supporting evidence per attributed record, counted once each |
| Comparative evaluator result | Supporting evidence per attributed record, as machine evidence |
| Attributed harm | Contradicting evidence per record; each Contested; all still present |
| Attribution fails its evidence requirements | Exposure recorded, benefit Unknown, Reason says what was refused |
| Attribution names a record the run never saw, or a comparative result names another run | Invalid — nothing written |
| Same feedback ID, identical content — in any record order | AlreadyRecorded — nothing written twice, nothing counted twice |
| Same feedback ID, different content | Conflict — nothing written; the stored submission's records are reported back when you are authorized for its scope |
| One record's submission fails | The rest still apply; that one is Failed and Retryable |
| Cancelled part-way through | What was decided is returned; the rest are Failed and Retryable — never an exception |
| The same run already produced evidence for a record | EvidenceApplied with Counted: false — the existing independence rule |
An exposed record is Candidate, Quarantined, Stale, Superseded, or Revoked |
Exposure recorded, Ineligible, nothing written for it |
| An exposed ID does not exist in that scope, or is readable only through a sharing grant | Exposure recorded, Unresolved with the reason, nothing written for it |
| Run scope outside the authorization | Denied before any write |
More than MaxExposedRecords (64) exposed records |
Invalid — nothing written |
Retrying is how you recover, and it converges. Each attributed record's evidence ID and event ID are derived by
hash from the feedback ID and the experience ID, and the submission's OccurredAt is your own ObservedAt. So
resubmitting the identical feedback re-derives the identical identifiers: the ledger write is a no-op, and any
outstanding confidence submission replays instead of counting a second time. Read result.IsRetryable and resubmit
the same ExperienceReuseFeedback — do not build a new one. The set of exposed records is what is compared, not
the order you listed them in, so a retry assembled differently from the original still converges rather than
colliding.
The exposure is written before any score moves. The feedback ledger commits first, so what a run saw is durable
even if every confidence submission then fails. Each record is then submitted independently, which is what makes a
partial failure partial. One consequence is worth knowing: an attributed exposure's stored evidence_id says
which ID the submission uses, not that it landed — so an auditor joining the two ledgers uses a LEFT JOIN, and
reads a missing row as "attributed, not yet counted", which is exactly the work a retry converges on.
The fan-out is bounded by size, not by time. A submission may name at most MaxExposedRecords (64) records, and
each attributed one costs a scoped read plus its own transaction, run sequentially, with only your
CancellationToken as a time bound. There is deliberately no internal budget, unlike retrieval's: abandoning a
retrieval yields an empty result and the agent runs on, whereas abandoning half a fan-out would leave some records
moved and others not, with no way to tell which from a timeout alone. Pass a token with a deadline if you need one —
what was decided by then is still reported.
TrialLabel is for measuring, not for filtering afterwards. It names the experimental condition a run was
declared to belong to — "memory-enabled", "memory-disabled" — so a later measurement aggregates conditions that
were fixed in advance rather than subsets chosen once the results are in.
A record that is committed is already reusable: it is text-searchable the moment it lands. Indexing gives it a second way to be found — by meaning — and it is derived data throughout. Nothing about the canonical write depends on an embedding provider being up.
If an ExperienceIndexingService is registered, finalization embeds each record it commits, right after the commit:
var result = await finalization.FinalizeAsync(request, cancellationToken);
if (result.Indexing is { IsIndexed: false } indexing)
{
// Never a reason to treat the record as anything less than durable.
logger.LogWarning("Experience {Id} is {Status} but not indexed ({Outcome}, retryable: {Retryable}): {Reason}",
result.ExperienceId, result.Status, indexing.Outcome, indexing.IsRetryable, indexing.Failure?.Reason);
}Only the sanitized retrieval summary is embedded — the task ID, the sanitized task summary, and the reflection's lesson, the same three fields the text index analyzes. Attempts, tool calls, evidence, provenance, and environment metadata are never sent to a provider. The summary is read from the database at index time, not from a record the caller happens to be holding, so what is embedded is what is really stored, at the revision it is really stored at.
The two channels read the same fields but not necessarily the same length: the embedded summary is capped at
8,192 characters (ExperienceRetrievalSummary.MaxLength, so the hashed text and the text sent to a provider are
always identical), while 0003 analyzes the concatenation up to 100,000. A record whose summary and lesson together
run past 8 KB is therefore matched on more of its text by words than by meaning. Both caps are far past any
realistic summary.
Only records a search could actually return are embedded. The indexing scan applies the same status filter and
confidence floor the vector search applies, and the post-commit hook checks the record before calling anything, so
a Quarantined, Revoked, Superseded, or Candidate record's summary and lesson never leave the database for a
third party — its vector could never be returned anyway.
Each stored vector carries model ID, dimension, content hash, and source revision, kept entirely separate from lifecycle state. None of them ever influences eligibility, status, or reuse confidence; they exist so a write can be conditional, a re-index can be free, and a query vector is never compared with something it is not comparable with.
| Outcome | When | What was written |
|---|---|---|
Indexed |
The summary was embedded and stored | The vector and its descriptor |
Skipped |
This model already embedded exactly this text | Nothing — and no provider call was made |
Stale |
The record moved to a newer revision before the write landed | Nothing; the stored vector is unchanged. Retryable |
Missing |
The record no longer exists in this scope | Nothing, and no row is created — an in-flight write cannot resurrect a deleted record |
Ineligible |
The record's status or confidence means a search could never return it | Nothing, and nothing was sent to a provider |
ProviderFailed |
The provider threw, timed out, or returned a vector of the wrong width or with a non-finite component | Nothing. The record stays committed, durable, and text-searchable. Retryable |
IndexFailed |
The index itself failed or refused the write | Nothing. Retryable |
Denied |
The scope lies outside the authorization | Nothing was read, embedded, or written |
Re-indexing is explicit, scoped, and idempotent. It never runs on its own:
var pass = await indexing.ReindexAsync(
authorization,
new ReindexExperienceRequest(scope, ExperienceIds: null, Limit: 100), // bounded; pass again to page
cancellationToken);
logger.LogInformation("{Examined} examined, {Indexed} re-embedded, {Skipped} unchanged, {Failed} failed",
pass.Examined, pass.Indexed, pass.Skipped, pass.Failed);A pass is bounded and resumable: records are considered in ascending ExperienceId order, and pass.LastExaminedId
is the cursor to hand to the next pass's StartAfterId. Keep going until it comes back null, which is how a scope
larger than one page is walked to the end.
The content hash covers the model ID and the normalized summary, so a record whose vector already came from this model and this text is skipped before any provider call — running a pass twice over unchanged records costs one read and nothing else. Changing the model looks exactly like changing the text, which is the point: two models produce incomparable vectors, so "same text" alone must never be enough to skip.
The approximate-nearest-neighbour index is created out of band, because it needs a dimension no shipped migration can know:
await ExperienceVectorIndexMaintenance.EnsureHnswIndexAsync(dataSource, dimension: 1536, cancellationToken);It is optional — every search is correct without it, using an exact scan — it makes search approximate, and
building it locks the table for the duration, so run it from a maintenance path. See the
vectors README for why the embedding column is an
unconstrained vector and the index is a partial one over embedding::vector(n).
Finding experience that applies to a task is one Core call: ExperienceRetrievalService.RetrieveAsync. It asks the
storage adapter for scope-, status- and confidence-filtered text matches — and, when a vector channel is wired in,
for the same thing matched on meaning — decides the remaining eligibility itself, and ranks what survives, always
returning a structured result rather than throwing.
using AgentExperience.Core.Retrieval;
var result = await retrieval.RetrieveAsync(
new RetrieveExperienceRequest(
Authorization: authorization, // host-established; the request scope must lie inside it
Scope: scope, // the exact scope to retrieve within, never widened
TaskText: "refund ticket stuck on a lock",
RequiredEnvironmentAttributes: new Dictionary<string, string> { ["region"] = "us-east" },
CorrelationId: traceId),
cancellationToken);
if (result.TimedOut)
{
logger.LogInformation("Retrieval timed out for {CorrelationId}; the agent runs without memory", result.CorrelationId);
}
foreach (var ranked in result.Records) // highest score first, ties by ExperienceId ascending
{
logger.LogDebug("{Id} scored {Score} from {Components}",
ranked.Record.ExperienceId,
ranked.Score,
string.Join(", ", ranked.Components.Select(c => $"{c.Kind}={c.Value}*{c.Weight}")));
}Eligibility is decided before ranking, and nothing is scored before it is known to be reusable.
| Check | Where it runs | Effect |
|---|---|---|
| Scope | SQL | Only records in the request's exact scope; a foreign scope reveals nothing |
| Status | SQL | Only Validated and Reinforced. Candidate, Quarantined, Contested, Stale, Superseded, and Revoked are never returned, whatever their text match |
| Reuse confidence | SQL | Below RetrievalPolicy.MinimumConfidence (default 0.5) is excluded |
| Text match | SQL | PostgreSQL full-text search over task ID, task summary, and reflection lesson (analyzed up to 100,000 characters) |
| Vector match | SQL | pgvector cosine distance over the embedding of those same three fields (embedded up to 8,192 characters), filtered to the query's own model and dimension |
| Expiry | Core | Last lifecycle activity older than RetrievalPolicy.MaxAge is excluded. null (the default) means no expiry |
| Environment | Core | Every required attribute must equal the record's EnvironmentFingerprint.Metadata entry; a missing key excludes the record. A request with no required attributes sets EnvironmentUnrestricted on the result |
Scope, status, and the confidence floor are pushed into both channels as the same predicates, so neither can return something the other would have filtered out.
result.Excluded itemizes what the Core checks removed — expiry and environment — so "nothing matched" is
distinguishable from "something matched but was not reusable here". It is deliberately not a complete account of
everything filtered: scope, status, and the confidence floor are applied in SQL, so records they exclude never reach
Core and are never listed. That split is the point — a foreign-scope or revoked record must not be observable, even
as a count.
Two channels, one answer. When an embedding index and an embedding generator are both registered, the task text
is also embedded and searched as a vector, concurrently with the text search and inside the same timeout. The two
candidate lists are then deduplicated by ExperienceId, and a record found by both keeps the higher of its two
normalized relevances. Ranking runs once over the merged list, with the same five weights as before: there is no
sixth axis and no "found by both" bonus. An embedding can only make a record a candidate — it never decides
eligibility, status, or confidence.
A vector channel that cannot be trusted produces an explicit text-only answer, never a failure. The text
candidates still come back, and result.TextOnly is true with result.VectorFallback.Reason saying which:
TextOnlyReason |
When | Vector comparison attempted? |
|---|---|---|
NotConfigured |
No embedding index or no generator is registered — a supported, text-only deployment | No channel exists |
ProviderUnavailable |
The provider threw, cancelled for its own reasons (a client-side request timeout), or returned a query vector of the wrong width or with a non-finite component | No — caught before any query is issued |
ModelMismatch |
Every embedding stored in this scope came from a different model | No — excluded by the query's own predicate |
DimensionMismatch |
Every embedding stored in this scope is a different width | No — excluded by the query's own predicate |
VectorSearchFailed |
The vector search threw, was denied, or was refused as malformed | Attempted; nothing usable came back |
TextOnly is never set merely because the vector channel matched nothing: "nothing was semantically similar" and
"the vector channel could not be trusted" are different claims, and only the second one is a reason to look at your
wiring.
There is a recall ceiling, and it is visible. Each channel returns at most RetrievalPolicy.CandidateLimit
candidates (default 50), ordered by its own relevance, and ranking only ever sees those. So a record with a weaker
match but strong confidence, recency, or status is not ranked at all once that many stronger matches exist in both
channels: the weighting can only reorder what the ceiling let through. When either channel reaches its ceiling,
result.Truncated is true — the records beyond it are in no exclusion list either, because no eligibility check
ever looked at them. Raise CandidateLimit or narrow the task text when that matters. request.Limit may not
exceed CandidateLimit; a larger value is rejected rather than quietly capped.
Ranking is explainable. Every returned record carries all five normalized components (each in 0–1) and the effective weight applied to it, so the score is always reproducible from what the result holds.
| Component | Default weight | Normalized as |
|---|---|---|
| Relevance | 0.35 | ts_rank_cd of the text match, or 1 - cosine_distance / 2 of the vector match — whichever is higher for that record — normalized to 0–1 |
| Confidence | 0.25 | The record's ReuseConfidence |
| Recency | 0.15 | 2^(-age / RecencyHalfLife), half-life 30 days by default. Age is measured from UpdatedAt |
| Status | 0.15 | Reinforced 1.0, Validated 0.5 |
| Environment compatibility | 0.10 | 1.0 for a record that satisfied the request's required attributes — which every ranked record did, since a mismatch excludes it before ranking |
Weights must be finite, non-negative, and sum to 1 (within RankingWeights.SumTolerance); anything else throws
ArgumentOutOfRangeException at construction, so an invalid weighting can never reach a retrieval call. Ties sort by
ExperienceId ascending and ordinal, so the ordering is total and stable, and a golden fixture pins the default
ordering together with every component value.
"Recency" and "expiry" mean last lifecycle activity, not when the lesson was learned. Both read
ExperienceRecord.UpdatedAt, which every lifecycle commit bumps. A years-old lesson reinforced yesterday is one day
old by this measure: it scores as fully recent and never expires. That is deliberate — recent revalidation is
evidence the lesson still holds — but it is not a measure of how old the underlying knowledge is, and a policy that
needs one should not use MaxAge for it.
Bounded, and fail-closed. The whole call is bounded by RetrievalPolicy.Timeout (default 500 ms, maximum one
day), measured with an injected TimeProvider.
| Situation | Outcome | Records |
|---|---|---|
| Ran inside the timeout | Completed |
Every eligible record among the candidates considered, ranked and cut to the request's limit. Check result.Truncated: true means more matched than were considered |
| Exceeded the timeout | TimedOut (result.TimedOut), with the request's CorrelationId — never an exception |
Empty |
| Request scope outside the authorization | Denied |
Empty; neither channel is issued a query, and nothing is embedded |
| The text search failed, or a candidate from either channel could not be read, came back out of scope, or was returned twice | Failed, with result.Failure |
Empty, never unfiltered |
| The vector channel failed, timed out on its own, or was incomparable | Completed, with result.TextOnly and result.VectorFallback |
The text channel's eligible records, ranked |
| Caller cancelled | OperationCanceledException, unwrapped and distinct from the timeout |
— |
result.Failure.Reason is content-free and safe to log. result.Failure.Exception, when present, is whatever the
port threw — a driver message can quote SQL text or connection detail, so treat it as local diagnostics rather than
something to pass on.
Retrieval returns ranked records and the evidence for their ranking. Turning them into a labeled Historical Reference and injecting it into an agent is a separate step, described next — and retrieved content never becomes authority.
ExperienceContextProvider closes the loop. It is a MAF AIContextProvider that, before each invocation, retrieves
the applicable experience, re-checks each candidate one last time, asks the host's risk policy, and injects what
survives as one delimited, labeled Historical Reference message. The host adds it to the agent itself:
using AgentExperience.MicrosoftAgentFramework.Injection;
var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
ChatOptions = new ChatOptions { Tools = tools },
AIContextProviders =
[
new ExperienceContextProvider(retrieval, recordStore, new ExperienceInjectionOptions
{
ResolveRequest = context => new RetrieveExperienceRequest(
Authorization: hostAuthorization,
Scope: hostScope,
// Never `Last()`: the list can be empty, and mid-conversation the last message is a
// tool result, not the task. Retrieval caps task text at
// `ExperienceCandidateQuery.MaxTaskTextLength` (4096 characters).
TaskText: context.Messages
.LastOrDefault(m => m.Role == ChatRole.User && !string.IsNullOrWhiteSpace(m.Text))?.Text
?? taskDescription),
DecideInjection = d => riskPolicy.Allows(d.Current) ? InjectionDecision.Permit : InjectionDecision.Deny("risk policy"),
OnContextInjected = result => logger.LogDebug("Injected {Count}, omitted {Omitted}", result.InjectedCount, result.Omitted.Count),
}),
],
});Each record in the block carries its source (experience ID, source run ID, task ID), its confidence, its applicability (the rank score and every component with the weight applied to it, labeled as ranked at retrieval), when it was learned and last revalidated, the environment it came from, and an evidence summary — lesson, reuse guidance, preconditions, warnings, verification status, and evidence ID count. Attempts, tool calls, arguments, results, errors, and evidence detail are never serialized, so a captured payload cannot reach a model through injection.
The label is hygiene, not a security control. The block states that it is untrusted reference material and that nothing inside it authorizes anything. That wording helps a well-behaved model treat retrieved text as data and gives a human reading a transcript the provenance — it does not make a model obey, and this project does not claim it does. What actually stops an unauthorized call is the authorization boundary around tools and policy, which lives entirely outside the block. An integration test pins that down: a fake model obeys an injected instruction to call a guarded tool, and the approval boundary denies the call anyway.
| Situation | What the agent sees |
|---|---|
| Eligible records found | A delimited block, in rank order, within 8 records and 16 KB of UTF-8 (both configurable and validated) |
| Nothing matched, retrieval timed out or failed, or the final check overran its bound | No injected context at all; the agent runs normally, the outcome is reported, and nothing is fabricated |
| The request scope lies outside the host authorization | Nothing, reported as RetrievalDenied; no search is issued, and a foreign scope reveals nothing |
A record revoked, re-scoped, re-scored below the confidence floor, aged past MaxAge, environment-mismatched, or unreadable since retrieval |
It is absent from the block; the omission is recorded with the rule that dropped it and the stored record is untouched |
The host's DecideInjection denies a record |
Absent whatever its stored confidence or status; the denial is recorded and nothing is written |
| More records, or more bytes, than the limits allow | Whole records are dropped — never cut — and each omission is recorded as OverRecordLimit or OverByteBudget |
The final eligibility check runs immediately before the payload is built and re-applies every rule retrieval
applies — status, the reuse-confidence floor, MaxAge, and the request's required environment attributes — to the
record as it stands now, so it catches what changed since retrieval. What it cannot do is reach backwards: once a
block has been handed to a model, a later revocation cannot retract it, and the provider says so rather than
implying otherwise.
Injected blocks accumulate in a reused session. A block injected on one turn can stay in the AgentSession's
conversation, so a later turn shows the model the fresh block and the earlier ones. MAF filters the provider's
input to external messages, so it cannot reliably see or strip its own earlier blocks, and it does not pretend to.
That means MaxBytes bounds one injected block rather than a conversation, and revocation only affects injections
that have not happened yet. Use a fresh session per task where either matters.
See the adapter README for the payload shape, the options, and the failure behaviour.
Scope is otherwise all-or-nothing: a record is readable only from the exact scope that owns it. A sharing grant is the one, audited exception. An administrator names one record, one recipient scope, a reason, and an expiry, and that recipient can read that record until the grant expires or is revoked.
using AgentExperience.Storage.Postgres.DependencyInjection;
services.AddAgentExperiencePostgresGrantStore(); // IExperienceGrantStore
// The host decides who may administer sharing. This is a separate, explicit input: it is never
// derived from an AuthorizationContext, from a role string, or from the requesting scope.
var administration = new GrantAdministration(
AdministratorPrincipalId: currentUser.Id,
AuthorizedAt: DateTimeOffset.UtcNow);
var result = await grants.CreateAsync(
hostAuthorization, // the caller's own authority, over the owner scope
administration, // authority to administer sharing
new ExperienceGrantRequest(
GrantId: Guid.NewGuid(),
ExperienceId: recordId,
RecordScope: ownerScope, // where the record lives: team-a
RecipientScope: ownerScope with { TeamId = "team-b" },
Reason: "team-b owns the follow-up work",
ExpiresAt: DateTimeOffset.UtcNow.AddDays(7)),
cancellationToken);
// Created — the grant row and its audit event were written in one transaction.A grant's lifetime is bounded, and there is no permanent grant. PostgresExperienceGrantPolicy carries the
maximum lifetime a new grant may be issued with — 90 days by default — and an expiry further ahead than that is
Invalid on ExpiresAt with nothing written. DateTimeOffset.MaxValue is refused like any other over-long
expiry; an expiry exactly at the maximum is accepted. The bound is a policy on the store, not a hidden constant:
services.AddAgentExperiencePostgresGrantStore(new PostgresExperienceGrantPolicy(TimeSpan.FromDays(30)));It binds a grant when it is created and never afterwards. Raising the maximum does not extend a grant already
issued, and lowering it does not shorten one — end an over-long grant by revoking it. The existing rule that an
expiry may only ever shrink is unchanged. Underneath the policy the database keeps its own fixed, generous ceiling
(expires_at <= issued_at + interval '10 years'), so a writer that bypasses this library still cannot store a
grant that never ends.
What a grant permits. Reading, and only reading: GetAsync, the text channel, the vector channel, and
therefore injection, which re-reads through the same call. A granted record comes back exactly as its owner sees
it, still carrying the owner's scope. Creating records, committing lifecycle changes, reading lifecycle history,
listing what a scope holds, and issuing further grants are never inferred from a grant, and still need the caller's
own authority.
What a grant can never do.
| Rule | Where it is enforced |
|---|---|
Relaxes only TeamId, AgentId, UserId; tenant, application, and project are always the record's own |
Validation with the field path, and a CHECK constraint, so an unstorable grant is unstorable |
| Confers no write, no lifecycle history, and no enumeration | Every non-read statement keeps the exact-scope predicate |
Stops permitting reads once ExpiresAt passes |
The read predicate, against clock_timestamp() — the database's wall clock, never the caller's, and never the transaction's start time |
| Stops permitting reads the moment it is revoked | The same predicate; revocation appends an event and deletes nothing. At most one grant per (record, recipient scope) may be active at a time, so revoking the grant you know about really is the end of that recipient's access -- a second, overlapping one is refused as Conflict rather than stacked |
| Cannot be issued to last longer than the configured maximum, and can never be permanent | Invalid on ExpiresAt with nothing written, and a fixed CHECK ceiling underneath it |
| Cannot be issued or revoked without administrator authority | Denied, before any connection is opened |
| Changes nothing about the record: not its status, confidence, counters, or revision | The grant path never touches experience_records |
Grant enforcement lives in SQL, alongside the existing scope predicate, so the database can never return a record the predicate did not permit and no application code is in a position to widen one. Revoking is an append:
await grants.RevokeAsync(
hostAuthorization,
administration,
new ExperienceGrantRevocation(grant.GrantId, ownerScope, "the collaboration ended"),
cancellationToken);
// Revoked — the next read is denied, and the grant's history keeps both events.
var history = await grants.ListAsync(hostAuthorization, ownerScope, recordId, cancellationToken);
// Every grant over the record, revoked and expired ones included. Owner scope only: a recipient
// cannot enumerate the grants over a record it can read.Nothing about sharing weakens eligibility. A shared record still has to be Validated or Reinforced, still has
to clear the confidence floor, expiry, and environment checks, and is ranked exactly like an owned one.
A borrowed lesson is labelled as one. The adapter is the only layer that knows a record came back through a
grant, so it says so: the flag travels on ExperienceCandidate.SharedByGrant and RankedExperience.SharedByGrant,
reaches the host's risk policy as ExperienceInjectionDecisionContext.SharedByGrant, and the injected Historical
Reference block carries a Shared: line (with no scope identifier in it). Everything downstream keeps its strict
"this must be my own record" check for anything that is not flagged, so a source that returns a foreign record
without declaring a grant is still dropped.
Two trails, and they answer different questions. experience_grant_events records administration -- who
allowed what, under authority established when, until when, and when they stopped allowing it -- and
IExperienceGrantStore.GetHistoryAsync reads one grant's trail. It answers "who permitted this?".
"Who read it?" is the access log, a separate, optional ledger:
services.AddAgentExperiencePostgresGrantAccessLog(
onNotRecorded: failure => logger.LogError(failure.Failure, "grant access row not written"),
mode: ExperienceGrantAuditingMode.BestEffort); // or RequiredWith it wired, every record a grant delivers appends a row naming the grant, the record and the revision that
was disclosed, the owner scope, the recipient scope, the reading principal, the host's correlation ID for the
work behind the read, and when. The read also tells the caller which grant permitted it —
ExperienceRecordGetResult.PermittingGrantId and ExperienceCandidate.PermittingGrantId, carried on to
RankedExperience and to the host's ExperienceInjectionDecisionContext — so an injected lesson can be tied back
to the sharing decision behind it. The grant ID is for the host: the injected block still names no grant and no
scope.
What is audited. Every read that hands a caller a record it does not own. That is GetAsync (the
pre-injection re-read included) and both search channels: ExperienceCandidate.Record is the record read back
in full, so a host consuming IExperienceCandidateSource or IExperienceEmbeddingIndex directly receives
complete foreign records — a search result is a disclosure, not a notice that something matched. A search's rows
are written in one statement, so auditing costs one round trip per search rather than one per row.
Two reads are not deliveries and write nothing. An owner reading its own record: no grant permitted it. And a read
the caller refuses after fetching it because a grant is what made it readable — declare it with
ExperienceReadOptions(ExperienceReadPurpose.ScopeCheck), as Core's confidence path does, since a grant never
confers writing.
Reading the trail is IExperienceGrantAccessLog.QueryAsync: owner scope only, bounded and cursored, mirroring
IExperienceGrantStore.GetHistoryAsync. A recipient cannot enumerate who else read a record it can read.
Auditing never fails silently. BestEffort — the default — returns the records and reports the failed write
through onNotRecorded, which is why that callback is required rather than optional. Required fails closed: a
GetAsync returns NotFound, the same answer a record no grant permitted would give, and a search returns no
candidates at all rather than the subset that needed no grant. A blank AuthorizationContext.PrincipalId is itself
an audit failure — a row that cannot say who read the record does not answer the question the ledger exists for.
Rows are append-only in the database, so a delivery cannot be edited or deleted out of the trail afterwards.
What it costs. Every disclosing read does a second, synchronous round trip on a pooled connection before it
returns, and under Required read availability becomes a function of write availability. That is the mode's
promise, not a bug; the dataSource overloads exist largely so the ledger can have its own pool and a slow ledger
cannot exhaust the connections reads depend on. Wire nothing and auditing is off: no extra write, no extra round
trip, no extra failure mode, and a deployment behaves exactly as before. Registrations use TryAdd, so a host that
registered its own store or candidate source first keeps it — and takes on the obligation to honour the policy
itself.
Source-compatible, not binary-compatible. Everything below is additive at the source level — defaulted positional parameters, defaulted constructor arguments, and one default interface method — so code recompiles unchanged. None of it is binary-compatible, so recompile rather than drop in the new assemblies:
| Type | Change |
|---|---|
ExperienceRecordGetResult |
gained Guid? PermittingGrantId = null |
ExperienceCandidate |
gained Guid? PermittingGrantId = null |
ExperienceCandidateQuery, ExperienceVectorQuery |
gained string? CorrelationId = null |
RankedExperience, ExperienceInjectionDecisionContext |
gained Guid? PermittingGrantId = null |
IExperienceRecordStore |
gained a defaulted GetAsync(..., ExperienceReadOptions, ...) overload that forwards to the existing one |
PostgresExperienceRecordStore, PostgresExperienceCandidateSource, PostgresExperienceEmbeddingIndex |
constructors gained ExperienceGrantAuditing? auditing = null |
PostgresExperienceGrantStore |
constructor gained PostgresExperienceGrantPolicy? policy = null, TimeProvider? timeProvider = null |
The one behavioural break is deliberate: a grant issued with an expiry more than 90 days out — including
DateTimeOffset.MaxValue — is now Invalid. Configure PostgresExperienceGrantPolicy if your deployment needs a
different window.
Two deployment notes. Reading through a grant needs SELECT on agent_experience.experience_grants; a role
without it, or a database that has not applied 0005 yet, falls back to the exact-scope predicate -- which narrows
what a read returns rather than failing it -- and reports it once through the reader's optional
onGrantsUnavailable callback. And NotFound does not mean a GrantId is free: the insert reads the record row
first, so a create naming a record that is not in the owner scope selects nothing and reports NotFound before the
primary key is ever tested -- even when that GrantId is already stored. Only Created and Conflict say anything
about the ID, so generate a fresh one per attempt rather than inferring availability from NotFound.
Each package registers its own services, so a host never names a concrete type:
using AgentExperience.Core.DependencyInjection;
using AgentExperience.Storage.Postgres.DependencyInjection;
using AgentExperience.Storage.Postgres.Vectors.DependencyInjection; // optional: the vector channel
services.AddSingleton(NpgsqlDataSource.Create(connectionString));
services.AddAgentExperiencePostgresStore(); // IExperienceRecordStore
services.AddAgentExperiencePostgresCandidateSource(); // IExperienceCandidateSource
services.AddAgentExperiencePostgresGrantStore(); // IExperienceGrantStore, optional: only a host
// that shares records across scopes needs it
services.AddAgentExperiencePostgresReuseFeedbackStore(); // IExperienceReuseFeedbackStore, optional: only a
// host that records reuse feedback needs it
services.AddAgentExperiencePostgresGrantAccessLog( // IExperienceGrantAccessLog, optional: only a host
onNotRecorded: failure => logger.LogError( // that wants to know who read shared records
failure.Failure, "grant access row not written")); // needs it. Off unless wired.
services.AddAgentExperiencePostgresEmbeddingIndex(); // IExperienceEmbeddingIndex
services.AddAgentExperienceEmbeddingGenerator(); // IExperienceEmbeddingGenerator, over a registered
// IEmbeddingGenerator<string, Embedding<float>>
services.AddAgentExperienceCore(sanitizationOptions, captureLimits);
// -> ISanitizer, IExperienceCaptureService, IExperienceReflector,
// ExperienceLifecycleService, ExperienceFinalizationService
services.AddAgentExperienceIndexing(); // ExperienceIndexingService, and finalization's
// post-commit hook, in either registration order
services.AddAgentExperienceRetrieval(); // ExperienceRetrievalService
// -> defaults to RetrievalPolicy.Default and RankingWeights.Default; pass your own to override
// -> hybrid, because an index *and* a generator are registered; text-only, and flagged, if either is missing
services.AddAgentExperienceReuseFeedback(); // ExperienceReuseFeedbackService, over the ledger
// above and the lifecycle service
// Injection has no registration of its own: ExperienceContextProvider needs a per-host resolver and
// risk decision, so the host constructs it and adds it to ChatClientAgentOptions.AIContextProviders.
// See "Injecting Historical Reference into MAF" above.Schema comes in two calls, matching that split:
await ExperienceSchemaMigrator.MigrateAsync(dataSource, cancellationToken); // 0001-0003 and 0005-0009, always
await ExperienceVectorSchemaMigrator.MigrateAsync(dataSource, cancellationToken); // 0004, only with the vector channelThe vector registrations and the second migration are optional, and genuinely so: leave them out and everything
still works — finalization commits records with no indexing hook, and retrieval answers from text alone with
TextOnly set to NotConfigured. That is also why the embedding schema is not in the base adapter's script list:
CREATE EXTENSION vector needs a superuser, and a text-only deployment must never be made to run it for a feature
it has not enabled.
AgentExperience.Abstractions stays BCL-only; only Core and the storage adapter take
Microsoft.Extensions.DependencyInjection.Abstractions, and every registration uses TryAdd, so a host's own
implementation wins.
The MAF adapter can drive finalization for you: set FinalizationService and ResolveFinalization on
ExperienceCaptureOptions and every successfully captured invocation is finalized right after it is completed. See
the adapter README.
- Hexagonal core.
Abstractionsdepends only on the BCL;Coreadds a redaction primitive and the dependency-injection abstractions it needs to register its own services. MAF, databases, models, and telemetry stay in adapters. Dependency-boundary tests enforce this in CI. - Failure-preserving capture. Failed and cancelled runs are recorded through an outer lifecycle path, never only a success callback.
- Evidence before trust. Verification is deterministic and bound to a host-closed round and artifact revision. A completion score is never mistaken for reuse confidence.
- Sanitize before anything is stored. Unknown payload fields are dropped by default, and secrets are redacted from nested values.
- Reuse, don't rebuild. MAF middleware and
Microsoft.Extensions.Compliance.Redactionare used at the edges, and storage builds on Npgsql and pgvector rather than on a bespoke engine. Each integration was proven with executable compatibility tests before an adapter was built. - Derived data never blocks canonical data. Embeddings are produced after the commit, through a replaceable provider port, and every failure leaves the record committed, text-searchable, and retryable.
src/
AgentExperience.Abstractions/ domain contracts and ports (BCL only)
AgentExperience.Core/ sanitization, capture, verification, reflection, lifecycle transitions, finalization, indexing, retrieval, reuse feedback
AgentExperience.MicrosoftAgentFramework/ MAF adapter: run/tool capture and Historical Reference injection (pinned Microsoft.Agents.AI 1.20.0)
AgentExperience.Storage.Postgres/ PostgreSQL Experience Record store, text search, sharing grants, reuse feedback ledger, and schema migrator (pinned Npgsql 10.0.3, dbup-postgresql 7.0.1, dbup-core 6.1.1)
AgentExperience.Storage.Postgres.Vectors/ pgvector embedding index, conditional writes, scoped re-index, and vector search (pinned Npgsql 10.0.3, Pgvector 0.3.2, Microsoft.Extensions.AI.Abstractions 10.9.0)
tests/
AgentExperience.Abstractions.Tests/ contract and dependency-boundary tests
AgentExperience.Core.Tests/ sanitizer, capture, verification, reflection, lifecycle, indexing, retrieval tests
AgentExperience.MicrosoftAgentFramework.Tests/ real ChatClientAgent runs against a scripted fake model
AgentExperience.Storage.Postgres.Tests/ store tests, mostly against a PostgreSQL container
AgentExperience.Storage.Postgres.Vectors.Tests/ embedding index and hybrid retrieval, against a pgvector container
AgentExperience.CompatibilityProof/ executable proofs for MAF hooks, context providers, pgvector, redaction
docs/ original production architecture research
_sdlc/ product brief, PRD, architecture, epics, and specs
Requires the .NET SDK 10.0.302 or a later feature band (see global.json).
dotnet restore
dotnet build
dotnet testUnit and MAF adapter tests run in memory, with no network, database, or model credentials. No test anywhere needs model credentials: every embedding in the test suite comes from a deterministic in-test generator. AgentExperience.CompatibilityProof, the PostgresExperienceRecordStoreTests, PostgresExperienceCandidateSourceTests, PostgresLifecycleCommitTests, PostgresSupersessionAndAppendOnlyTests, PostgresGrantTests, PostgresConfidenceEvidenceTests, PostgresReuseFeedbackTests, PostgresFinalizationTests, and ExperienceSchemaMigratorTests in AgentExperience.Storage.Postgres.Tests, the PlainPostgresMigrationTests in the same project (a stock postgres:16 image, proving the base schema needs nothing pgvector provides), and the PostgresEmbeddingIndexTests and HybridRetrievalIntegrationTests in AgentExperience.Storage.Postgres.Vectors.Tests start a PostgreSQL/pgvector container through Testcontainers, so they need Docker. If Testcontainers' Ryuk container fails to start under your local Docker setup, set TESTCONTAINERS_RYUK_DISABLED=true. To skip the container-backed tests:
dotnet test --filter "FullyQualifiedName!~CompatibilityProof&FullyQualifiedName!~PostgresExperienceRecordStoreTests&FullyQualifiedName!~PostgresExperienceCandidateSourceTests&FullyQualifiedName!~PostgresLifecycleCommitTests&FullyQualifiedName!~PostgresSupersessionAndAppendOnlyTests&FullyQualifiedName!~PostgresGrantTests&FullyQualifiedName!~PostgresConfidenceEvidenceTests&FullyQualifiedName!~PostgresReuseFeedbackTests&FullyQualifiedName!~PostgresFinalizationTests&FullyQualifiedName!~ExperienceSchemaMigratorTests&FullyQualifiedName!~PlainPostgresMigrationTests&FullyQualifiedName!~PostgresEmbeddingIndexTests&FullyQualifiedName!~HybridRetrievalIntegrationTests"- Capture and explain agent experience ✅ contracts, sanitization, capture, verification, reflection, MAF adapter
- Reuse relevant experience ✅ PostgreSQL persistence, atomic audited lifecycle commits, one-call finalization of captured runs, bounded text retrieval with explainable ranking, revision-safe embedding ingestion with hybrid retrieval, and historical-reference injection into MAF
- Govern experience safely ✅ explicit sharing grants, the full audited lifecycle transition table with supersession and database-enforced append-only logs, evidence-based confidence updates, and recording experience reuse feedback
- Operate and measure the learning loop: OpenTelemetry instrumentation, an end-to-end demo, measured reuse against a baseline, data deletion and expiry
Full requirements and acceptance criteria are in _sdlc/planning-artifacts/epics.md.
Development is spec-driven with the BMAD Method and AI-assisted implementation. The planning trail is versioned alongside the code:
- Product brief, PRD, architecture, and epics:
_sdlc/planning-artifacts/ - MVP spec and reuse-boundary decisions:
_sdlc/specs/
Each story is planned against the architecture, implemented against explicit acceptance criteria, and then reviewed by independent adversarial, edge-case, and verification-gap passes before it is committed.
Issues and pull requests are welcome. See CONTRIBUTING.md and the Code of Conduct. To report a vulnerability, follow SECURITY.md.