/** * What a verification can FIND — the vocabulary of faults and contradictions shared by the crawl, * the contradiction hunter and the tools that report them. */ import { ChannelId } from '../wire/channel.js'; /** * Cross-channel contradictions: two observation channels making INCOMPATIBLE claims about the same * action. This is the bug class a human structurally cannot see, because a human has one channel * open — the screen. The agent holds DOM, store, signals, console and network in one causally * ordered window and can notice they disagree. * * Every kind here describes a shipped-today false green: the run is green, the screen looks right, * and the app is wrong. Contrast `CrawlAnomalyKind`, whose members are all SINGLE-channel facts (an * error was logged; a request failed) — those are findable by reading one stream. */ /** * Two channels disagreeing about the same action — the shape every rule reports. * * Lives here, beside the kinds, because it is part of the artifact contract: a replayed step and a * driven one both carry these, and a shape declared twice is a shape that drifts. * * `kind` is typed `string`, not `ContradictionKind`, because the vocabulary is open at the EDGE. The * enum below enumerates what the engine's own rules emit and every one of those is still checked * against it; a rule registered by a consumer emits kinds this package has never heard of and must * not have to be added here to be reportable. A shared closed enum is exactly how a consumer's * private vocabulary ends up shipped in the free product by accident. */ export interface Contradiction { kind: string; /** What one channel asserted — the optimistic half. */ claim: string; /** What the other channel asserted — the half that contradicts it. */ counter: string; /** Concrete evidence, so the agent can go straight to the call or the control. */ detail: string; } export declare const ContradictionKind: { /** The screen moved forward while a request in the same window failed — the swallowed rejection. */ readonly UI_ADVANCED_REQUEST_FAILED: "ui-advanced-request-failed"; /** The app fired its own success signal while a request in the same window failed. Strongest form: * the app did not merely look right, it explicitly ASSERTED success against its own evidence. */ readonly SIGNAL_CONTRADICTED: "signal-contradicted"; /** A write succeeded on the server and nothing on the client moved — the response went nowhere. */ readonly RESPONSE_IGNORED: "response-ignored"; /** * The app fired a success signal and NOTHING else in the window moved — no DOM, no store, no * route, no request. The only evidence that anything happened is the app's own claim. * * `DEAD_CONTROL` describes almost exactly this and deliberately excludes it: its definition is * "dispatched but the app did NOTHING (no DOM/net/route/signal)", so firing a signal was enough to * rescue a control that did nothing at all. That is the hole. An app whose signal is emitted from * the value it was ASKED for rather than the one it COMMITTED gets a verdict at signal grade — * the strongest grade there is — for a click that changed nothing. On the bench fixture: a * modal that never opened came back `verified: "yes" / proved`. * * Absence-derived on purpose (see below). "Nothing corroborated it" is not "it did not happen": * a signal about genuinely non-visual state is legitimate. This downgrades the verdict to UNKNOWN * so the false green cannot stand, without asserting a fault nobody proved. */ readonly SIGNAL_WITHOUT_CONSEQUENCE: "signal-without-consequence"; /** * The store committed and the screen never moved. * * `response-ignored` covers *a write succeeded and nothing moved*; `action-had-no-effect` covers * *the click did nothing at all*. Neither covers the commonest render defect there is: state * commits and the component that should have re-rendered does not — a memo with a stale * comparator, a key that never changes, a selector reading the wrong slice. The app is internally * consistent and the user is looking at the old value. * * Absence-derived, for the same reason as the entry above: a store holds plenty that was never * meant to paint — an analytics flag, a timer, a cached token — so "nothing rendered" is not proof * of a fault. It removes the false green and never invents a NO. */ readonly STATE_VS_RENDER: "state-vs-render"; /** * A write succeeded on the server and nothing moved in THIS document — because the page opened * another browsing context (an OAuth popup is the archetype) and the consequence lives there, * where an in-page SDK cannot follow. Reported instead of response-ignored, which would read as * "the client ignored the response" about a client that did no such thing. */ readonly CONSEQUENCE_ELSEWHERE: "consequence-elsewhere"; /** The same write fired more than once in one action — double-submit / retry storm. */ readonly DUPLICATE_REQUEST: "duplicate-request"; /** * The same write fired repeatedly on an endpoint the assertion never named. * * A poll that bursts, a batched analytics beacon, a retry loop the caller was not asking about. * The finding is real and worth reporting; what it is NOT is evidence about the consequence the * caller declared. An app that polls could not produce a verdict at all -- every assertion that * had already seen its consequence came back `unknown` behind writes it never mentioned (#673). * * ADVISORY, so it rides out in `contradictions` and decides nothing. `DUPLICATE_REQUEST` proper * -- a burst on an endpoint the assertion DID name -- keeps its downgrade. */ readonly DUPLICATE_REQUEST_UNRELATED: "duplicate-request-unrelated"; /** * Two reads of the same endpoint were in flight together and settled OUT OF ORDER, so the one the * user asked for first is the one that landed last — and the screen is showing it. * * The classic filter/search race. It needs no bug in either request: whichever query the server * happens to answer more slowly wins, so the UI shows data for a query the user has already * replaced. Every channel reports success — both requests are 200, the control shows the new * selection, the page settles — which is why it survives review and why `settled` cannot catch it. * * Detectable only from a request TIMELINE, which is why a screenshot or a DOM snapshot can never * see it: the evidence is the interleaving, and by the time anyone looks, the interleaving is gone. */ readonly STALE_RESPONSE_APPLIED: "stale-response-applied"; /** * A request returned 2xx and reported failure inside its BODY. * * The status line describes the transport, not the outcome. GraphQL returns 200 for every error it * has; a bulk endpoint returns 200 for the batch and puts per-item failures in the payload; a * gateway-normalised API returns 200 with `success: false`. Every channel above the body — status, * UI advance, settle — agrees with the optimistic reading, which is exactly why this survives. */ readonly PARTIAL_FAILURE_IN_OK_RESPONSE: "partial-failure-in-ok-response"; /** * A money value was sent back to the API at a different SCALE than the API stated it. * * Payment APIs speak minor units (paise, cents) as integers; a UI renders major units; writing the * rendered number back into the same field is a 100x error the server accepts and reports as * processed. Detectable only by holding the request timeline with bodies — the number sent is * exactly the number on the screen, so every other channel agrees it is correct. */ readonly UNIT_MISMATCH: "unit-mismatch"; /** * A write returned success and its OWN echo shows a field it was asked to set was not applied. * * Ordinary in real backends: a column missing from the UPDATE, a schema stripping unknown keys, a * PATCH honouring a subset, an enum falling back to a default. The status is 2xx, the body reports * no failure, the UI advanced, the page settled — so every channel except the payload agrees the * save worked, and the screen goes on showing the value the user typed rather than the value that * was stored — a write asking for `locale: fr` and echoing `locale: en`. */ readonly WRITE_FIELD_IGNORED: "write-field-ignored"; /** The UI advanced while a request was still in flight, so `settled` was reported over a live call. */ readonly REQUEST_NEVER_SETTLED: "request-never-settled"; /** * The SERVER faulted (5xx) and the app blamed the USER — "invalid credentials" for a broken * backend, "not permitted" for a crashed service. The user is told to fix something they cannot * fix, and the real fault is never reported. A support ticket that costs hours to trace back. */ readonly FAILURE_MISATTRIBUTED: "failure-misattributed"; /** * The action was dispatched and NOTHING happened — no DOM, no store, no route, no request, no * signal, no console line. The click landed on something that does not react. * * A contradiction rather than a warning because two channels disagree: the act layer reports * `dispatched: true, settled: true`, and every observation channel reports an empty window. The * settle half is the trap — a page that does nothing is quiet, and quiet is exactly what `settled` * tests for, so `until: { kind: 'settled' }` PASSES on a dead click and the verdict read * `verified: "yes", because: "no channel disagreeing"`. The shape that produces it: a `by: 'text'` * query resolves a `styled.div` instead of the button beside it, and the click lands on nothing. */ readonly ACTION_HAD_NO_EFFECT: "action-had-no-effect"; /** * The route changed and NOTHING was rendered for it — no content added, none removed, no request. * The URL says you arrived somewhere; the page is blank. * * This is the class every "did the control work" heuristic misses, because the control DID work: * it navigated. A crawler counting dead controls reports none, correctly by its own definition, * since a route change IS activity. * * The discriminator: a working nav emits `domAdded: 1, network: 2`; a blank one emits * `domAdded: 0, domRemoved: 0, network: 0`. A real transition either fetches something or renders * something. * * THE LIMIT IS REAL, and is not fixable by tuning: a route whose view is REVEALED from DOM that * already existed emits `route.change` + `dom.attr` and nothing else, which is byte-for-byte the * window a blank destination emits. No event-only rule separates them. Over-warning is the safe * direction (a false alarm costs a glance; a blank page shipped as working does not), so this is * reported with the ceiling stated rather than tuned into silence. */ readonly ROUTE_RENDERED_NOTHING: "route-rendered-nothing"; /** * The route changed, nothing was rendered for it, AND the window holds a console error — the same * shape as `ROUTE_RENDERED_NOTHING`, but with positive evidence of WHY the destination is blank, * not merely its absence. * * `ROUTE_RENDERED_NOTHING` is deliberately absence-derived: a route that renders nothing might be a * false positive (a view revealed from DOM that already existed — the 1-in-11 case measured on its * own doc comment), so it downgrades to UNKNOWN rather than assert a fault nobody proved. That * caution is wrong for THIS case. A console error in the same window is not an absence of * evidence, it is a specific, positive claim — the destination crashed, and the app's own error * boundary or console said so. Reported `unknown` anyway once, when a route-rendered-nothing * window also carried a React hooks error: the console errors and the empty destination were both * in hand, and the honest answer was available and not given. * * NOT in `ABSENCE_DERIVED_CONTRADICTIONS` — this is exactly the "evidence AGAINST the action" * category that set exists to distinguish itself from, and it is graded `NO` for the same reason * `ui-advanced-request-failed` is: a definitive verdict is available and inconclusive is a weaker, * wrong answer to give when it is. */ readonly ROUTE_RENDERED_NOTHING_CRASHED: "route-rendered-nothing-crashed"; /** * Everything this window held belongs to a document that has SINCE been replaced. * * Not a disagreement between channels; a disagreement between the evidence and the clock. A window * is scoped by time and by ring-buffer capacity, so it can still hold the network calls, console * errors and signals of a page that a full navigation or a reload has already thrown away. Citing * one of those as the cause of an action taken now is true about the bytes and false about the * world — reported from the field with a failing request that named a database row which no longer * exists. * * Dropping that evidence is only half the fix. The other half is that its absence must not read as * "nothing happened", which would trade a wrong citation for a wrong all-clear, and an all-clear is * the more expensive of the two. This kind is what the engine says instead, and the distinction is * the whole user-visible point: an agent told its evidence was superseded knows to re-drive, and an * agent told the window was empty does not. */ readonly EVIDENCE_SUPERSEDED: "evidence-superseded"; /** * Everything this window held was observed BEFORE the last source edit landed in the page. * * The sibling of `EVIDENCE_SUPERSEDED`, for the loop an agent actually runs: verify, edit source, * verify again. A hot update replaces modules and re-renders inside the SAME document, so the * document id — the only thing that could previously say "this evidence is about a page that is * gone" — never moves, and observations of code the agent has already rewritten go on answering * for it in silence. * * Reported rather than EXCLUDED, and the difference from the document case is deliberate. A * navigation is total: it throws away the page, the refs, the in-flight requests and the state, so * nothing recorded under it is still about the world. An edit is not. Most modules, most of the * DOM, the whole network log and every console line survive a hot update, so most of what was * observed a second before it is still true a second after. Dropping that window would empty * verdicts that hold real findings, and an emptied window reads as "nothing happened" — the more * expensive of the two wrong answers, and the one this family of checks exists to prevent. * * So the evidence stays and the caveat is said out loud: absence-derived, because nothing here * claims the app is wrong. It downgrades a verdict to unknown, which is the honest reading of * "you changed the code and then looked only at what happened before you did". */ readonly EVIDENCE_PREDATES_EDIT: "evidence-predates-edit"; }; export type ContradictionKind = (typeof ContradictionKind)[keyof typeof ContradictionKind]; /** * Contradictions inferred from the ABSENCE of evidence in a window whose end Reticle itself chose. * * The distinction decides whether Reticle is entitled to say an action FAILED. The other kinds are * POSITIVELY observed — a request came back 500 while the UI advanced, a signal fired carrying data * that disagrees with the DOM, a written field echoed a different value — and they must keep * outranking a passing assertion, because a green assertion on top of a failed write is the bug * class this product exists to catch. * * These five say only "the thing I expected had not happened YET when I stopped looking", and the * window closes the moment the predicate first passes — on an app that navigates optimistically, * routinely before the network drains. Letting one assert NO makes a timing observation overrule a * consequence observation, which inverts the grade hierarchy the verifier is built on: the bench app * produces a `no` over a fired signal with matching data, changed state, a stored token and a clean * capture, because one POST had not settled. * * A false negative is not the mirror of a false positive here. A false positive stops an agent * early; a false NEGATIVE makes it redo work that already succeeded, or stop trusting the verdict * channel — and the verdict channel is the product. * * So these downgrade a verdict to UNKNOWN rather than asserting NO. The finding is still reported in * `contradictions` either way — nothing is hidden, and an agent that wants to wait and re-check has * everything it needs to. */ export declare const ABSENCE_DERIVED_CONTRADICTIONS: ReadonlySet; /** True when this kind was inferred from absence rather than positively observed. */ export declare function isAbsenceDerived(kind: string): boolean; /** * Findings that are REPORTED and decide nothing. * * The two existing tiers both move a verdict: OBSERVED answers `no`, ABSENCE_DERIVED downgrades to * `unknown`. Neither fits a fact that is true, worth telling the caller, and simply not about the * question the caller asked. * * That gap had a cost. An app that polls on an interval could not produce a verdict at all: a camera * scan loop POSTing until it acquired a lock had every assertion -- ones that had already seen the * person recognised, the heading, the 200s -- come back `unknown`, because writes the assertion * never mentioned counted against it. The caller then reports `pass: true` with correct evidence and * has to explain in prose that Reticle's own verdict is wrong, which erodes the reason to have a * verdict (#673). * * The bar for adding a kind here is high, and it is not "this rule is noisy". It is that the finding * cannot, even in principle, be evidence about the declared consequence -- because it concerns * traffic the assertion did not name. */ export declare const ADVISORY_CONTRADICTIONS: ReadonlySet; /** True when this kind is reported alongside a verdict without changing it. */ export declare function isAdvisory(kind: string): boolean; /** * How much authority a finding carries — the distinction above, said out loud on the finding itself. * * The rule already turns on this: an OBSERVED contradiction outranks a passing assertion and answers * `no`, an ABSENCE_DERIVED one downgrades to `unknown`. But the findings all arrive looking alike, so * an agent handed three of them cannot tell which one decided the verdict and which is a note about * when Reticle stopped looking. Two facts of very different strength, reported in one voice. */ export declare const FindingTier: { /** * Positively observed evidence AGAINST the action: a request came back 500 while the UI advanced, a * signal fired carrying data the DOM disagrees with, a written field echoed a different value. * Something happened, and it is incompatible with the action having worked. */ readonly OBSERVED: "observed"; /** * Inferred from something NOT having happened yet, in a window whose end Reticle chose. It may * become true a moment later. It is a statement about the window's timing at least as much as * about the app. */ readonly ABSENCE_DERIVED: "absence-derived"; /** * True, reported, and not about the question asked. Traffic the assertion never named. * * Distinct from ABSENCE_DERIVED, which IS about the declared consequence and says the timing was * inconclusive about it. This one is about something else entirely, so downgrading on it answers a * question nobody put. */ readonly ADVISORY: "advisory"; }; export type FindingTier = (typeof FindingTier)[keyof typeof FindingTier]; /** * The tier of a finding, DERIVED from its kind. * * Deliberately a lookup rather than a field an oracle sets. An oracle that stated its own tier would * be grading its own homework, and the one thing every author of a new rule is sure of is that their * finding is important. The kind decides, in one place, where the verdict rule already reads it. * * An unrecognised kind is OBSERVED, and that default is doing real work rather than being a fallback. * A rule registered by a consumer emits kinds deliberately absent from this vocabulary — that is what * keeps somebody's private finding names out of the free product — and those findings still have to * be tierable. OBSERVED is the honest answer: an unknown kind has made no claim about a window whose * end Reticle chose, so downgrading it would invent a caveat on its author's behalf. */ export declare function tierOfFinding(kind: string): FindingTier; /** * HTTP methods that CHANGE server state. Several contradiction rules are restricted to these on * purpose: a GET that fires without moving the UI is a prefetch, but a POST that does is a lost * write. Narrowing to writes is what keeps the rules from crying wolf on ordinary reads. */ export declare const MUTATING_METHODS: readonly string[]; /** * Which two channels each contradiction sets against each other. * * `Record` on purpose: a new kind does not compile until somebody says what * it compares. That is the point -- the independence rule is only checkable if the pairing is * written down, and until now it was carried in the head of whoever added the rule. * * The pairing is causal, not observational: it names where each side of the disagreement was * PRODUCED. A screenshot is `ui` even though it is taken out of process, because the pixels came * from the render the action caused. */ export declare const CONTRADICTION_CHANNELS: Record; /** * May this kind of disagreement be reported as a fault in the app? * * False when both sides come from the app's own actuation path. Such a disagreement is real and * worth reporting -- the app contradicted itself -- but it is not evidence that the action failed, * and treating it as such is how a verdict channel earns a reputation for crying wolf. */ export declare function contradictionCanConvict(kind: ContradictionKind): boolean; export declare const CrawlAnomalyKind: { readonly CONSOLE_ERROR: "console-error"; readonly FAILED_REQUEST: "failed-request"; readonly DEAD_CONTROL: "dead-control"; }; export type CrawlAnomalyKind = (typeof CrawlAnomalyKind)[keyof typeof CrawlAnomalyKind];