/** * Freshness-safe prior content — PURE comparison core (the fresh-snapshot conjunct only). * * This narrows the measurement window; it does not close TOCTOU. Content identity is proven AT * MEASUREMENT TIME — a host that then writes unconditionally still races. Closing that needs a * host-side conditional write (compare-and-swap on a version token), which this package never * performs; see conditional-write.ts. * * Settled decisions (do not relitigate): * 1. Freshness is CONTENT IDENTITY, not time. No TTL. Byte identity of `before` vs gate-time resolve. * 2. The caller NAMES (artifact id); the gate RESOLVES prior content. A caller-supplied `before` * is a claim, not a measurement — no fallback ladder that relabels a guess as measured. * 3. Four outcomes, never collapsed: TARGET_MUTATED | STALE_CONTEXT | TAMPERED | UNKNOWN_FRESHNESS. * 4. STALE_CONTEXT fails closed by default; policy may opt out deliberately. * 5. Tree hash is OPTIONAL in the binding. Without it, STALE_CONTEXT is skipped, TARGET_MUTATED * still runs, and the result SAYS the reduced level (never silently pretends full check ran). * * Merge-gate pattern generalized: recompute from current inputs; compare to what preflight bound; * do not trust a claim about itself. Tree hash stays OUT of verdict_fingerprint and input_fingerprint * (those preimages are frozen — their frozenness is what makes recompute meaningful). * * PURE: no I/O, no network, no Date.now(). Host supplies resolved content as inputs. */ /** * Four outcomes. Callers distinguish them by the `outcome` field (never by message text alone). * * TARGET_MUTATED — the contract `before` bytes moved (field removal, type change, …) * STALE_CONTEXT — repository path-set tree moved, contract `before` identical (e.g. format-only * neighbouring files, or raw blob change with same contract content). Message * MUST state this is not tampering. * TAMPERED — contract moved AND tree moved (both axes disagree) * UNKNOWN_FRESHNESS — a candidate was named, but current state is not measurable (missing resolve) * * Plus FRESH when both applicable axes match. */ export type FreshnessOutcome = 'FRESH' | 'TARGET_MUTATED' | 'STALE_CONTEXT' | 'TAMPERED' | 'UNKNOWN_FRESHNESS'; /** * Which axes were eligible to run. * full — tree hash was bound; contract + tree both evaluated * contract_only — no tree hash on the binding; only TARGET_MUTATED axis ran (reported, not silent) */ export type FreshnessCheckLevel = 'full' | 'contract_only'; export type FreshnessAssessInput = { /** * Contract `before` that entered preflight (and thus digests / the decision). Bound measurement — * not a host claim at execute time. */ preflightBefore: string; /** * Gate-resolved prior content for the same artifact id NOW. * null/undefined = current state not measurable → UNKNOWN_FRESHNESS. * The gate (or host adapter feeding the gate) supplies this by resolving the caller's artifact id; * a caller-supplied `before` string must NOT be passed here as if measured. */ resolvedBeforeNow: string | null | undefined; /** * Optional tree commitment bound at preflight (additive; NOT in input_fingerprint / verdict_fp). * Absent/empty → level is contract_only; STALE_CONTEXT axis is skipped. */ preflightTreeHash?: string | null; /** * Tree commitment recomputed at gate time from the same path set + current blobs. * Only consulted when preflightTreeHash is present. * null/undefined when tree was bound but cannot be re-measured → UNKNOWN_FRESHNESS. */ resolvedTreeHashNow?: string | null | undefined; /** * Policy opt-out for STALE_CONTEXT only (default false = fail closed). * TARGET_MUTATED / TAMPERED / UNKNOWN_FRESHNESS are never opted out by this flag. * Same shape as an explicit team choice (approver allowlist): say so deliberately. */ allowStaleContext?: boolean; }; export type FreshnessAssessResult = { outcome: FreshnessOutcome; /** Which axes ran. Always set — even on FRESH — so a reduced check cannot look like a full one. */ level: FreshnessCheckLevel; /** True when the gate must refuse enforced execution. */ failClosed: boolean; /** Contract before bytes match (only meaningful when resolvedBeforeNow was measurable). */ contractMatch: boolean | null; /** Tree match; null when tree axis skipped or unmeasurable. */ treeMatch: boolean | null; /** * Human message. For STALE_CONTEXT always includes that this is not tampering. * Machine consumers should branch on `outcome`, not parse the message. */ message: string; }; /** * Write-style call: path + new content only is the shape the composition residual names. * Gateable requires an artifact identifier the gate can resolve — not a caller-supplied before. */ export type WriteStylePriorInput = { /** Artifact id from a prior preflight artifacts[] entry (caller names; gate resolves). */ artifactId?: string | null; path?: string | null; /** New content (the write payload). */ after?: string | null; /** * If present, this is a CLAIM. It is never treated as a measurement and never substitutes * for artifactId. Recorded only so tests can assert there is no "use claimed before" ladder. */ claimedBefore?: string | null; }; export type WriteStylePriorResult = { gateable: boolean; reason: 'ok' | 'MISSING_ARTIFACT_ID' | 'CLAIMED_BEFORE_NOT_MEASUREMENT'; message: string; /** Always false — claimedBefore is never elevated to a measurement. */ claimedBeforeAcceptedAsMeasurement: false; }; /** Byte identity of two content strings (freshness definition: not time, not TTL). */ export declare function contentByteIdentical(a: string, b: string): boolean; /** * Optional path-set tree commitment. Additive only — never fold into input_fingerprint or * verdict_fingerprint. Preimage: sorted path + sha256(content), then outer sha256. * Missing content for a path is the caller's problem: pass only complete pairs. */ export declare function computePathSetTreeHash(entries: ReadonlyArray<{ path: string; content: string; }>): string; /** * Assess freshness of prior content by recompute-and-compare (merge-gate pattern generalized). * * Pure. Does not read the filesystem; the host/adapter resolves artifact id → content and passes * `resolvedBeforeNow` / optional tree hashes as inputs. */ export declare function assessFreshness(input: FreshnessAssessInput): FreshnessAssessResult; /** * Write-style prior requirements (Decision 2). * Caller must supply artifactId. claimedBefore is never accepted as measurement. */ export declare function assessWriteStylePrior(input: WriteStylePriorInput): WriteStylePriorResult; /** * Convenience: whether enforced execution is allowed for this freshness result. * Equivalent to !failClosed: FRESH always; STALE_CONTEXT only when the team opted out; * TARGET_MUTATED / TAMPERED / UNKNOWN never. */ export declare function freshnessAllowsEnforce(result: FreshnessAssessResult): boolean; /** * Whether a resolver ran for THIS call. * ACTIVE — resolver was supplied and returned content for at least one artifact id * NOT_CONFIGURED — no resolver on the host config; we never attempted measurement * DEGRADED — resolver was supplied and threw, or returned nothing usable * * NOT_CONFIGURED ≠ UNKNOWN_FRESHNESS. The latter is an assessFreshness outcome after a measurement * was attempted with empty/unusable resolved content under ACTIVE wiring. */ export type FreshnessWiringState = 'ACTIVE' | 'NOT_CONFIGURED' | 'DEGRADED'; export type FreshnessDegradeReason = 'resolver_threw' | 'resolver_returned_empty' | 'resolver_returned_non_string'; /** * Per-call forensic basis. Rides on the GuardOutcome so a receipt chain audited later can see, * call by call, whether the check ran — not only a composition-level "wired?" flag. */ export type FreshnessBasis = { wiring: FreshnessWiringState; /** Present when wiring === ACTIVE and assessFreshness ran against preflight before. */ assessment?: FreshnessAssessResult; /** Present when wiring === DEGRADED — never report DEGRADED as NOT_CONFIGURED. */ degrade?: { reason: FreshnessDegradeReason; detail?: string; }; /** * Whether this call was treated as write-style (path + new content / needs prior resolve). * Forensic: opt-out on a write is not silent. */ write_style: boolean; /** Config requireFreshness for this call (policy). */ require_freshness: boolean; }; /** Host callback: artifact id → current prior content. Async I/O lives here — never inside assessFreshness. */ export type PriorContentResolver = (req: { artifactId: string; path?: string; toolName: string; }) => Promise | string | null | undefined; /** * Values the RUNNER collected before entering the pure guard path. * The guard never invokes the resolver; it only consumes these values. */ export type FreshnessCallContext = { wiring: FreshnessWiringState; /** artifactId → resolved current before (only when wiring ACTIVE or partial DEGRADED with empties) */ priorResolved?: Record; degrade?: { reason: FreshnessDegradeReason; detail?: string; }; }; /** * Detect write-style: contract-relevant path present, but no both-side artifacts on the descriptor. * (edits[]/old_string+new_string binders already lift both sides — those are not write-style.) */ export declare function isWriteStyleCall(call: { artifacts?: ReadonlyArray<{ before?: unknown; after?: unknown; }> | undefined; arguments?: unknown; filesTouched?: string[]; }): boolean; /** * Detect MUTATION: does this call change a resource? Deliberately independent of artifacts[]. * * WHY THIS IS NOT isWriteStyleCall: that predicate answers a different question — "did the caller * supply a both-sides snapshot we can compare against?" — because freshness must MEASURE a before * it does not trust (see f14e5a3: "a caller-supplied before is a claim, and a claim is not a * measurement"). Correct there. But carrying before AND after says nothing about whether the commit * was atomic: those are different facts, and reusing the freshness predicate for the * conditional-write policy let an ordinary Edit — which the default binder lifts into artifacts[] * with both sides — suppress the atomicity demand entirely. * * Evidence of mutation, any one of: * - an artifact carrying a non-empty `after` (the call states a resulting state), or * - content-bearing arguments: contents / content / new_string / old_string / patch, or * - a non-empty edits[] array. * A path alone is NOT evidence — a read names a path too. */ export declare function isMutatingCall(call: { artifacts?: ReadonlyArray<{ before?: unknown; after?: unknown; }> | undefined; arguments?: unknown; filesTouched?: string[]; }): boolean; /** Artifact ids the runner should ask the host to resolve (from lifted artifacts or path-derived id). */ export declare function artifactIdsForResolve(call: { toolName?: string; artifacts?: ReadonlyArray<{ id?: unknown; }> | undefined; arguments?: unknown; }): string[]; /** * RUNNER-side collection. Invokes the host resolver (async I/O). Never call from pure assessFreshness. * - no resolver → NOT_CONFIGURED (did not try) * - resolver throws → DEGRADED (opted in, failed) * - resolver returns empty/non-string for all ids → DEGRADED * - otherwise ACTIVE with priorResolved map */ export declare function collectFreshnessCallContext(input: { call: { toolName: string; artifacts?: ReadonlyArray<{ id?: unknown; before?: unknown; }> | undefined; arguments?: unknown; }; resolvePriorContent?: PriorContentResolver | null; }): Promise; /** * Build the per-call basis and optional enforce block reason from context + preflight artifacts. * Pure once priorResolved is a value (no I/O). */ export declare function buildFreshnessBasis(input: { writeStyle: boolean; requireFreshness: boolean; ctx: FreshnessCallContext; /** Preflight-bound before per artifact id (from call.artifacts after detect/bind). */ preflightBeforeById?: Record; allowStaleContext?: boolean; preflightTreeHash?: string | null; resolvedTreeHashNow?: string | null; }): { basis: FreshnessBasis; /** If set, enforced execution must not proceed (permission fail-closed). */ blockCause?: 'FRESHNESS_REQUIRED' | 'FRESHNESS_FAILED'; }; //# sourceMappingURL=freshness.d.ts.map