What the assistant knows
The rules behind an assistant's knowledge of the account, the person and the data: one writer per fact, rebuilds driven by input hashes, one readiness rollup, specs that pull evidence, activities with lift, and only vocabulary kept.
An assistant is only as good as what it knows about the person’s world. What tables exist and what’s in them. Which features this account can use. What this person actually works on. Which answers are proven.
The machine underneath is one knowledge store: observers write facts that decay, and a scheduler spends a budget on what’s most worth re-checking. This post covers the rules that keep that store honest as more things write to it and read from it.
One writer per fact
The agent saw a value live while answering and wrote it straight into knowledge. That skipped the prober’s guards, raced the prober’s own write, and gave one fact several writers with different rules.
So each fact has one address and one writer:
type Ref = `${"data" | "account" | "user" | "answer"}:${string}:${string}`;
// data:hop:Order|SHIPPED_BY|Carrier account:feature-fit:late-orders user:activity:refunding
await facts.observe(ref, { outcome: "confirmed", by: "probe", digest }); // the owner measures; compare-and-set
await facts.request(ref, "generation"); // everyone else asks
Open requests are the requests newer than the newest real measurement. A measurement answers every request before it, so there’s no queue to drain and nothing to delete. Same-day requests from the same source fold into one, so a busy fact’s history spans days rather than minutes.
Rebuild on input hash, not a clock
“Is this account ready for the late-orders feature?” depends on integrations, plan features, the data and the feature’s own requirements. Recomputing it on every read is slow, a timer lags, and a scoring change leaves old rows looking valid.
So a derived fact stores a hash of exactly what it was built from:
const due = (d: Derivation<T>, t: T) => {
const now = d.inputs(t); // undefined: inputs not all there yet
if (now === undefined || now === d.builtFrom(t)) return null;
return { demand: d.demand?.(t) ?? 0 };
};
const readinessInputs = (a: Account) =>
hash([a.integrations, a.planFeaturesDigest, a.dataFingerprint, a.failingTypes, CATALOG_VERSION, SCORER_VERSION]);
Readers never compute: they serve what’s stored and file a request. The code’s version is an input, so a scoring change rebuilds every row. Noise stays out of the hash. Every integration’s “last succeeded” time moves constantly and the score never reads it, so including it would rebuild forever.
Readiness with a veto
“How ready is this account?” had four answers from four computations. And an average hides the thing that matters: 92% coverage with the orders table unreadable isn’t ready.
So there’s one trust function and one rollup:
type Trust = "proven" | "believed" | "stale" | "unknown";
const READY_AT = 80, NOT_READY_BELOW = 40, READY_TOP = 10; // placeholders until calibrated against answer quality
function rollup(items: Item[]): Readiness {
const covered = (t: Item) => !t.failing && (t.trust === "proven" || t.trust === "believed");
const coverage = 100 * sum(items.filter(covered), weight) / sum(items, weight);
const vetoed = byWeight(items).slice(0, READY_TOP).some((t) => t.failing);
const level = coverage < NOT_READY_BELOW ? "not-ready" : coverage >= READY_AT && !vetoed ? "ready" : "almost-ready";
return { level, percent: Math.round(coverage), gap: level === "not-ready" ? undefined : gapSentence(items) };
}
// weight: half how much data an item has, half how much people actually use it
A failure among the ten heaviest items blocks “ready” whatever the percentage. Gap text names types, never values: “Still learning Shipment”. A not-ready account shows no number, and a failed read keeps the last answer instead of flipping a ready account off.
Specs that pull their own evidence
Feature gating as hand-written code drifts, “not checked yet” becomes “not supported”, and a flaky integration turns a feature off.
So a feature’s needs are a JSON spec, judged three ways:
{ "id": "late-orders", "needs": [
{ "level": "REQUIRED", "label": "Order", "minCount": 1 },
{ "level": "REQUIRED", "hop": { "from": "Order", "verb": "SHIPPED_BY", "to": "Carrier" }, "minCoverage": 0.5 },
{ "level": "IMPROVES", "fill": { "label": "Order", "property": "promisedAt" }, "minFill": 0.6 },
{ "level": "ADVISORY", "signal": "integration_health", "category": "shipping" } ] }
- An unmet required need makes the feature not ready.
- An unknown one makes it UNKNOWN, never unsupported. (Unknown is not absent.)
- Advisory needs never gate.
Judging a spec also lists the facts that would settle its unknowns, and requests up to 25 of them. Every account judged against a spec pulls in the evidence that settles it.
Activities with lift
Personalizing from page views picks up navigation, autosave bursts and the dashboard everyone opens on Monday. Hand-assigned roles are wrong for most people.
So a person’s focus is an activity: a verb they keep coming back to, and do more than their colleagues.
const SESSION_GAP_MS = 30 * 60_000, WINDOW_DAYS = 30;
const MIN_SESSIONS = 3, MIN_DAYS = 2; // recurring, not one long afternoon
const MIN_LIFT = 1.5, SMOOTHING = 1;
const lift = (mine: number, othersMean: number) => (mine + SMOOTHING) / (othersMean + SMOOTHING);
- Verbs, not pages. A closed registry maps real actions to verbs, and navigation never counts.
- One vote per minute. Each verb counts once per minute, so an autosave burst can’t outvote a deliberate act.
- Sessions split on 30 idle minutes. Each session is labelled by its dominant verb.
- The bar for an activity: at least 3 sessions on 2 days, with a lift of 1.5 over colleagues.
- “Not mine” is permanent. It stops the activity being inferred for good. “Hide” lasts 30 days.
This is what the planner gets to see.
Keep only the vocabulary
Grounding needs real values, like the statuses an order can have. Sampling naively copies one person’s name into a long-lived store, then into prompts. One sampled property turned out to hold device recovery keys.
const RETAINED_MIN_SHARE = 3; // a value fewer records share is about a record, not the vocabulary
const SECRET_NAME = /password|passphrase|secret|credential|private_?key|recovery_?key|unlock_?pin|api_?key|(access|refresh|session|auth)_?token/i;
const NON_SECRET_VALUE = /^(true|false|\d{4}-\d{2}-\d{2}(T\S*)?|\d{13})$/i;
- Shared values only. A value fewer than three records share is never kept.
- People stay out. Email-shaped values are never kept, even when shared.
- Credentials keep flags and dates. A property named like a credential keeps only those.
- Usage signals keep structure. They record a query’s shape, never its literals.
The guard runs both when a fact is written and when it’s loaded, so rows from before the rule are covered too.
Only independent evidence raises trust
If every reuse of a cached answer counts as a confirmation, a popular answer proves itself forever. So the system’s own reuse never confirms, and a run’s result is a reading, not a verdict:
if (o.outcome === "used" || o.outcome === "requested" || o.outcome === "measured") continue;
if (o.by === "reuse" && o.outcome === "confirmed") continue; // we can't vouch for ourselves
const PROVEN_ANSWER = { a0: 4, b0: 1, confirmHalfLife: "14d", refuteHalfLife: "2d" }; // one run → 0.83
An answer comes back only for the question it was proven for: the same skeleton plus what the question named. On reuse, the stored query may add to what was asked, never contradict it or leave part out. A person’s verification is never demoted.
Hot header, one cold read
What the model reads splits by temperature. A short, capped header rides every turn:
Works on: Refund handling, Supplier onboarding, Late orders and 2 more.
Ready: Late orders, Refunds. Almost ready: Supplier scorecards (needs: carrier data).
Knowledge: almost-ready, 73%. Still learning Shipment.
Everything else is one tool, get-knowledge({ about: "data" | "account" | "user" }), which replaced five profile tools the model had to guess between. Every list in the header ends with “and N more”, so the model knows there’s more to read. Anything the person wrote for the assistant is quoted and labelled as their preferences, never mixed into the rules.
Why it works
Each rule closes a way knowledge goes wrong as more things touch it: two writers, a stale derivation, a hidden failure, an unknown read as no, a leaked value, a self-confirming answer. The store stays one machine, and what the model reads from it stays true.
Part of a series: The UI is the engine, An agent loop that always answers, Generation you can check.