- {SETTINGS_SYSTEM_CONTRIBUTES.ora} Switching it on sends the live web address of each watched page to
- Ora, whose scans are public: the result enters Ora's directory and anyone can read it. Webflow
- staging addresses are never sent.
-
+ {/*
+ The Ora row IS the consent control, and it stays exactly where it is.
+ What it gains is the retention sentence — the half of the disclosure it
+ did not say — and the record of who changed it, beneath. The disclosure
+ reads ABOVE the control because it is what somebody needs before
+ deciding, not an explanation of what they just did.
+
+ Stacked, because the card now holds two things: the row, and the record
+ beneath it. Without this the card's own flex would lay the history out
+ BESIDE the control as a third column. `--stacked` is S8's existing
+ modifier and `.settings-consent__row` reproduces the original row inside
+ it, so the Ora row itself looks exactly as it did.
+ */}
+
diff --git a/src/app/api/settings/agent-audits/route.ts b/src/app/api/settings/agent-audits/route.ts
index 8a7d255..425cb95 100644
--- a/src/app/api/settings/agent-audits/route.ts
+++ b/src/app/api/settings/agent-audits/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { setExternalAgentAuditEnabled } from "@/lib/mutations";
import { projectStore } from "@/lib/projects";
+import { identityFromRequest } from "@/lib/identity";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -12,8 +13,15 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "enabled must be a boolean" }, { status: 400 });
}
try {
+ // The verified account, not anything the body claims: the history records
+ // WHO consented, which is the only thing that makes it a consent record.
+ const identity = await identityFromRequest(req);
return NextResponse.json({
- state: await setExternalAgentAuditEnabled(body.enabled, await projectStore(req)),
+ state: await setExternalAgentAuditEnabled(
+ body.enabled,
+ { kind: "person", userId: identity.email },
+ await projectStore(req),
+ ),
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
diff --git a/src/app/globals.css b/src/app/globals.css
index 3e28ccb..3780386 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1083,6 +1083,56 @@ button:disabled {
}
}
+/* ── Consent, on the Ora row ───────────────────────────────────────────── */
+
+/*
+ The Ora row's original layout, unchanged, now that the card also holds the
+ history beneath it. The row keeps its shape; only the card grew.
+*/
+.settings-consent__row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.settings-consent {
+ margin-top: 14px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border-hairline);
+}
+
+.settings-consent__label {
+ margin: 0;
+ color: var(--text-body);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.settings-consent__list {
+ margin: 6px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.settings-consent__entry {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 10px;
+ padding: 3px 0;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.settings-consent__none {
+ margin: 6px 0 0;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
/* ── Excluded from results ─────────────────────────────────────────────── */
.excluded-list {
@@ -2602,6 +2652,11 @@ textarea:focus-visible,
align-items: stretch;
}
+ .settings-consent__row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
.guide-toolbar {
position: static;
}
diff --git a/src/components/agent-access.tsx b/src/components/agent-access.tsx
index cf2b0de..f15a3e2 100644
--- a/src/components/agent-access.tsx
+++ b/src/components/agent-access.tsx
@@ -4,6 +4,9 @@ import { useState } from "react";
import Link from "next/link";
import type { AgentIssueCase, AgentIssueSource, AgentIssueStatus } from "@/lib/agentIssueCases";
import { systemLabel } from "@/lib/agentIssueCases";
+import { readingPredatesWithdrawal } from "@/lib/agentConsent";
+import { SETTINGS_CONSENT_STALE_READING } from "@/lib/settings-copy";
+import type { ExternalAgentConsentEntry } from "@/lib/types";
import {
agentAgreement,
AGENT_ACCESS_SOURCES,
@@ -163,12 +166,19 @@ function ReadingRow({
reading,
locale,
highlight,
+ consent,
}: {
reading: AgentReading;
locale?: string;
highlight?: boolean;
+ consent?: AgentAccessConsent;
}) {
const excluded = reading.applicability === "excluded";
+ // Only Ora's row. Kitesurf is not gated by this consent, and a clause about a
+ // permission that never governed a reading would be a claim about it that is
+ // simply untrue.
+ const stale = reading.source === "ora"
+ && readingPredatesWithdrawal(consent?.history, consent?.on === true, reading.observedAt);
return (
{agentExcluded(reading.reason)}
)}
+ {/* A reading taken while Ora was connected, on a project that has since
+ disconnected. It is a real reading and it stays exactly as legible as
+ the others — not greyed, not removed, not reordered. All the clause
+ does is say the permission behind it is gone, so a reader is not left
+ wondering why a source that is off has a row at all. The string is
+ imported rather than written here: Settings says it too, and two
+ renderers of one sentence is what rule 20 forbids. */}
+ {stale && (
+
{SETTINGS_CONSENT_STALE_READING}
+ )}
{AGENT_RESULT_LABEL[reading.result]}
@@ -217,15 +237,31 @@ function ReadingRow({
* getting in or being understood; one who reads on gets the single next step
* and then, underneath, every reading it was drawn from — unmerged.
*/
+/**
+ * What the ledger needs to know about consent, and nothing more.
+ *
+ * The live boolean and the record behind it. Both, because "is this reading
+ * stale" cannot be answered from the boolean alone: a project that connected,
+ * disconnected and connected again has readings from two permitted stretches,
+ * and only the history says which side of the current withdrawal each one
+ * falls on.
+ */
+export interface AgentAccessConsent {
+ on: boolean;
+ history: readonly ExternalAgentConsentEntry[];
+}
+
export function AgentAccessPanel({
access,
caseHref,
locale,
+ consent,
}: {
access: AgentAccess;
/** Resolves a family key to `/issues/{id}`. Absent while no case exists yet. */
caseHref?: (key: string) => string | undefined;
locale?: string;
+ consent?: AgentAccessConsent;
}) {
const agreement = agentAgreement(access);
const href = access.primary ? caseHref?.(access.primary.key) : undefined;
@@ -265,7 +301,7 @@ export function AgentAccessPanel({
{disagreement && (
{disagreement.map((reading) => (
-
+
))}
)}
@@ -305,6 +341,7 @@ export function AgentAccessPanel({
reading={reading}
locale={locale}
highlight={conflicting.has(reading.source)}
+ consent={consent}
/>
))}
diff --git a/src/lib/__tests__/agent-consent.test.ts b/src/lib/__tests__/agent-consent.test.ts
new file mode 100644
index 0000000..becba04
--- /dev/null
+++ b/src/lib/__tests__/agent-consent.test.ts
@@ -0,0 +1,280 @@
+import { readFileSync } from "node:fs";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, describe, expect, it } from "vitest";
+import { createFsStore, type DataStore } from "../store/fsStore";
+import { setExternalAgentAuditEnabled } from "../mutations";
+import {
+ appendConsentEntry,
+ consentCallerName,
+ consentWasEverGranted,
+ normalizeExternalAgentConsentHistory,
+ readingPredatesWithdrawal,
+} from "../agentConsent";
+import { SETTINGS_CONSENT_NEVER, SETTINGS_CONSENT_UNRECORDED } from "../settings-copy";
+import type { ExternalAgentConsentEntry } from "../types";
+import type { Caller } from "../caller";
+
+/**
+ * Consent as a record rather than a switch.
+ *
+ * The boolean is the live answer and the gate reads it; these are about the
+ * history behind it — that it moves with the boolean, that nothing prunes it,
+ * and that a stored reading can be asked which side of a withdrawal it falls
+ * on. Nothing here touches the gate itself; `agent-audit-isolation` owns that.
+ */
+
+const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
+const roots: string[] = [];
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+});
+
+const RAE: Caller = { kind: "person", userId: "rae@webflow.com" };
+const SAM: Caller = { kind: "person", userId: "sam@webflow.com" };
+
+const T1 = "2026-08-01T00:00:00.000Z";
+const T2 = "2026-08-05T00:00:00.000Z";
+const T3 = "2026-08-10T00:00:00.000Z";
+const T4 = "2026-08-15T00:00:00.000Z";
+
+const entry = (enabled: boolean, at: string, by: Caller = RAE): ExternalAgentConsentEntry =>
+ ({ enabled, at, by });
+
+async function store(): Promise {
+ const root = await mkdtemp(path.join(tmpdir(), "fde-consent-"));
+ roots.push(root);
+ const dataStore = createFsStore("test", root);
+ // The updater mutates in place; a returned object is not the state.
+ await dataStore.updateState((state) => {
+ state.pages = [];
+ state.recs = [];
+ state.followUps = [];
+ });
+ return dataStore;
+}
+
+/* ── Reading the record ─────────────────────────────────────────────────── */
+
+describe("the stored history", () => {
+ it("reads legacy state, which has none, as nothing recorded", () => {
+ expect(normalizeExternalAgentConsentHistory(undefined)).toEqual([]);
+ expect(normalizeExternalAgentConsentHistory(null)).toEqual([]);
+ expect(normalizeExternalAgentConsentHistory("on")).toEqual([]);
+ });
+
+ it("keeps every entry that is one, in the order it was written", () => {
+ const history = [entry(true, T1), entry(false, T2), entry(true, T3)];
+ expect(normalizeExternalAgentConsentHistory(history)).toEqual(history);
+ });
+
+ it("drops what is structurally not an entry, and only that", () => {
+ // The same treatment an unreadable provider snapshot gets: skipped rather
+ // than surfaced half-built. A record with no value, no timestamp or no
+ // identity is not a decision somebody made.
+ const kept = entry(true, T1);
+ expect(normalizeExternalAgentConsentHistory([
+ kept,
+ { at: T2, by: RAE },
+ { enabled: false, by: RAE },
+ { enabled: false, at: T2 },
+ { enabled: false, at: T2, by: { kind: "person" } },
+ { enabled: false, at: "", by: RAE },
+ null,
+ "off",
+ ])).toEqual([kept]);
+ });
+
+ it("never prunes a well-formed entry, however old or however contradicted", () => {
+ // Withdrawal is not a retraction. A grant from months ago stays in the log
+ // after it has been withdrawn twice over, because it is the only thing that
+ // can answer what was permitted when a stored reading was taken.
+ const history = [entry(true, T1), entry(false, T2), entry(true, T3), entry(false, T4)];
+ expect(normalizeExternalAgentConsentHistory(history)).toHaveLength(4);
+ expect(appendConsentEntry(history, true, SAM, "2026-08-20T00:00:00.000Z")).toHaveLength(5);
+ });
+});
+
+/* ── Writing it ─────────────────────────────────────────────────────────── */
+
+describe("changing consent", () => {
+ it("appends an entry and flips the boolean, together", async () => {
+ const dataStore = await store();
+ const on = await setExternalAgentAuditEnabled(true, RAE, dataStore, new Date(T1));
+ expect(on.externalAgentAuditEnabled).toBe(true);
+ expect(on.externalAgentAuditConsentHistory).toEqual([entry(true, T1)]);
+
+ const off = await setExternalAgentAuditEnabled(false, SAM, dataStore, new Date(T2));
+ expect(off.externalAgentAuditEnabled).toBe(false);
+ // The grant survives the withdrawal. Both are the record.
+ expect(off.externalAgentAuditConsentHistory).toEqual([entry(true, T1), entry(false, T2, SAM)]);
+ });
+
+ it("does neither when the value is not changing", async () => {
+ // Re-selecting the position a project is already in is not a decision, and
+ // recording one would put a change in the history that never happened.
+ const dataStore = await store();
+ await setExternalAgentAuditEnabled(true, RAE, dataStore, new Date(T1));
+ const again = await setExternalAgentAuditEnabled(true, SAM, dataStore, new Date(T2));
+ expect(again.externalAgentAuditEnabled).toBe(true);
+ expect(again.externalAgentAuditConsentHistory).toEqual([entry(true, T1)]);
+ });
+
+ it("records who, from the caller rather than from the request body", async () => {
+ const dataStore = await store();
+ const state = await setExternalAgentAuditEnabled(true, SAM, dataStore, new Date(T1));
+ expect(state.externalAgentAuditConsentHistory?.[0]?.by).toEqual(SAM);
+ expect(consentCallerName(SAM)).toBe("sam@webflow.com");
+ expect(consentCallerName({ kind: "system", agent: "migration" })).toBe("migration");
+ });
+
+ it("leaves a legacy project reading as off, with nothing recorded", async () => {
+ const dataStore = await store();
+ const state = await dataStore.getState();
+ expect(state.externalAgentAuditEnabled).toBe(false);
+ expect(state.externalAgentAuditConsentHistory).toEqual([]);
+ expect(consentWasEverGranted(state.externalAgentAuditConsentHistory, false)).toBe(false);
+ });
+});
+
+/* ── Was it ever on ─────────────────────────────────────────────────────── */
+
+describe("was this ever on", () => {
+ it("separates never-granted from granted-and-withdrawn", () => {
+ // The question the boolean cannot answer, and the reason the screen shows a
+ // line rather than an empty list.
+ expect(consentWasEverGranted([], false)).toBe(false);
+ expect(consentWasEverGranted([entry(true, T1), entry(false, T2)], false)).toBe(true);
+ });
+
+ it("says yes for a project that is on now but predates the record", () => {
+ // Legacy: the boolean shipped before the history did. "Never been connected"
+ // would be false about a project that plainly is.
+ expect(consentWasEverGranted([], true)).toBe(true);
+ });
+});
+
+/* ── A reading and the permission behind it ─────────────────────────────── */
+
+describe("a reading that predates a withdrawal", () => {
+ it("is marked when consent has since been withdrawn", () => {
+ expect(readingPredatesWithdrawal([entry(true, T1), entry(false, T3)], false, T2)).toBe(true);
+ });
+
+ it("is not marked while consent stands", () => {
+ // Nothing is stale on a connected project: the clause says the permission
+ // is gone, and it is not.
+ expect(readingPredatesWithdrawal([entry(true, T1)], true, T2)).toBe(false);
+ });
+
+ it("is not marked after a later re-grant — both sides", () => {
+ // The half that needs the history rather than the boolean. Same reading
+ // date, same project, two different answers depending on what the record
+ // says happened after it.
+ const withdrawn = [entry(true, T1), entry(false, T2)];
+ const regranted = [entry(true, T1), entry(false, T2), entry(true, T3)];
+ const takenBeforeTheWithdrawal = "2026-08-03T00:00:00.000Z";
+ expect(readingPredatesWithdrawal(withdrawn, false, takenBeforeTheWithdrawal)).toBe(true);
+ expect(readingPredatesWithdrawal(regranted, true, takenBeforeTheWithdrawal)).toBe(false);
+ // And a reading taken after the re-grant, on a project still connected.
+ expect(readingPredatesWithdrawal(regranted, true, T4)).toBe(false);
+ });
+
+ it("uses the most recent withdrawal when there have been several", () => {
+ // Two permitted stretches. A reading from either one predates the current
+ // withdrawal, so both carry the clause.
+ const twice = [entry(true, T1), entry(false, T2), entry(true, T3), entry(false, T4)];
+ expect(readingPredatesWithdrawal(twice, false, "2026-08-03T00:00:00.000Z")).toBe(true);
+ expect(readingPredatesWithdrawal(twice, false, "2026-08-12T00:00:00.000Z")).toBe(true);
+ });
+
+ it("marks nothing when consent was never withdrawn, and nothing undated", () => {
+ expect(readingPredatesWithdrawal([entry(true, T1)], false, T2)).toBe(false);
+ expect(readingPredatesWithdrawal([entry(true, T1), entry(false, T3)], false, undefined)).toBe(false);
+ // A date nobody can read is not evidence for a claim about when.
+ expect(readingPredatesWithdrawal([entry(true, T1), entry(false, T3)], false, "whenever")).toBe(false);
+ });
+});
+
+/* ── Where the words live ───────────────────────────────────────────────── */
+
+describe("one string, one home", () => {
+ it("has S4's ledger import the clause rather than restate it", () => {
+ const ledger = readFileSync(path.join(SRC, "components", "agent-access.tsx"), "utf8");
+ expect(ledger).toContain("SETTINGS_CONSENT_STALE_READING");
+ expect(ledger, "the ledger restates the clause instead of importing it")
+ .not.toContain("collected while Ora was connected");
+ });
+
+ it("keeps the toggle's option labels inline, matching the sibling control", () => {
+ // S8's file mixes two conventions and this follows both: sub-control and
+ // option labels are inline literals, sentence-length copy is a constant.
+ const page = readFileSync(path.join(SRC, "app", "(app)", "settings", "page.tsx"), "utf8");
+ expect(page).toContain('label: "Connected"');
+ expect(page).toContain('label: "Not connected"');
+ const copy = readFileSync(path.join(SRC, "lib", "settings-copy.ts"), "utf8");
+ expect(copy).not.toContain('"Connected"');
+ expect(copy).not.toContain('"Not connected"');
+ });
+
+ it("keeps every sentence-length string in the shared module", () => {
+ const page = readFileSync(path.join(SRC, "app", "(app)", "settings", "page.tsx"), "utf8");
+ for (const constant of [
+ "SETTINGS_CONSENT_HISTORY_LABEL",
+ "SETTINGS_CONSENT_NEVER",
+ "SETTINGS_CONSENT_UNRECORDED",
+ "SETTINGS_CONSENT_RETENTION",
+ "settingsConsentGranted",
+ "settingsConsentWithdrawn",
+ ]) {
+ expect(page, `${constant} is not read from the shared module`).toContain(constant);
+ }
+ expect(page, "the never line is written into the screen")
+ .not.toContain("has never been connected for this project");
+ });
+
+ it("gives both empty states a line, and suppresses neither", () => {
+ // Rule 18: an absent record is not nothing to report. A project connected
+ // before the record existed has a grant with no date, and says so; it does
+ // not fall through to "never connected", and the block is never hidden.
+ const page = readFileSync(path.join(SRC, "app", "(app)", "settings", "page.tsx"), "utf8");
+ expect(page).toContain("everGranted ? SETTINGS_CONSENT_UNRECORDED : SETTINGS_CONSENT_NEVER");
+ expect(page, "the history block is suppressed for a state that has something to say")
+ .not.toMatch(/entries\.length === 0 && everGranted\) return null/);
+ // The two lines are different sentences, so neither can stand in for the other.
+ expect(SETTINGS_CONSENT_UNRECORDED).not.toBe(SETTINGS_CONSENT_NEVER);
+ });
+
+ it("stacks the Ora card, so the history sits under the control and not beside it", () => {
+ // Found by looking at it, not by a test: `.settings-system` is a flex ROW,
+ // so a card with two children lays the history out as a third column next
+ // to the toggle. `--stacked` is S8's own modifier and
+ // `.settings-consent__row` reproduces the original row inside it, so the Ora
+ // row looks unchanged and the record lands beneath it.
+ const page = readFileSync(path.join(SRC, "app", "(app)", "settings", "page.tsx"), "utf8");
+ const card = page.indexOf('');
+ expect(card).toBeGreaterThan(-1);
+ const opensCard = page.lastIndexOf("
{
+ // One writer, so the boolean cannot move without the history moving with it.
+ const mutations = readFileSync(path.join(SRC, "lib", "mutations.ts"), "utf8");
+ // Assignments only — the guard above it compares, and `normalize` defaulting
+ // a missing field closed is not a change of consent.
+ expect(mutations.match(/state\.externalAgentAuditEnabled\s*=(?!=)/g) ?? []).toHaveLength(1);
+ });
+
+ it("discloses the consequence once, at the control", () => {
+ const page = readFileSync(path.join(SRC, "app", "(app)", "settings", "page.tsx"), "utf8");
+ // Rendered once. The import is the other occurrence and is not a rendering.
+ expect(page.match(/\{SETTINGS_CONSENT_RETENTION\}/g) ?? []).toHaveLength(1);
+ // And above the control, which is where somebody deciding needs it.
+ expect(page.indexOf("{SETTINGS_CONSENT_RETENTION}"))
+ .toBeLessThan(page.indexOf('ariaLabel="Ora"'));
+ });
+});
diff --git a/src/lib/agentConsent.ts b/src/lib/agentConsent.ts
new file mode 100644
index 0000000..d3086b8
--- /dev/null
+++ b/src/lib/agentConsent.ts
@@ -0,0 +1,131 @@
+import type { Caller } from "./caller";
+import type { ExternalAgentConsentEntry, StoredCaller } from "./types";
+
+/**
+ * The project's consent record for external agent audits: the history behind
+ * the boolean, and the one question a stored reading has to be asked.
+ *
+ * `externalAgentAuditEnabled` is the live answer and the gate reads it. This
+ * module never touches that gate — it exists so that the history and the
+ * boolean move together, and so that the rule for "was this permitted when it
+ * was taken" is written once rather than once per screen.
+ *
+ * Deliberately free of copy. What a history line SAYS is settings copy; which
+ * lines there are, and whether a reading predates a withdrawal, are facts about
+ * the record and belong here.
+ */
+
+/**
+ * The compiler's check that the stored caller IS `Caller`.
+ *
+ * `types.ts` imports nothing, so the shape is written out there. This is what
+ * makes drift a build failure rather than a history that describes a caller the
+ * app no longer has — the same device `case-decisions.ts` uses for the log.
+ */
+type SameSet
= [A] extends [B] ? ([B] extends [A] ? true : false) : false;
+export const STORED_CONSENT_CALLER_IS_THE_CALLER: SameSet = true;
+
+function isStoredCaller(value: unknown): value is StoredCaller {
+ if (!value || typeof value !== "object") return false;
+ const by = value as Partial & Record;
+ if (by.kind === "person") return typeof by.userId === "string" && by.userId.length > 0;
+ if (by.kind === "system") return typeof by.agent === "string" && by.agent.length > 0;
+ return false;
+}
+
+/**
+ * Read the stored history, keeping every entry that is one.
+ *
+ * An entry that is structurally not an entry — no value, no timestamp, no
+ * caller — is not a decision somebody made, and it is dropped on read the same
+ * way an unreadable provider snapshot is skipped rather than surfaced
+ * half-built. That is the only thing ever removed here: a well-formed entry is
+ * never pruned, never edited, never reordered, however old it is and whatever
+ * the boolean says today.
+ */
+export function normalizeExternalAgentConsentHistory(value: unknown): ExternalAgentConsentEntry[] {
+ if (!Array.isArray(value)) return [];
+ return value.flatMap((entry): ExternalAgentConsentEntry[] => {
+ if (!entry || typeof entry !== "object") return [];
+ const item = entry as Partial;
+ if (typeof item.enabled !== "boolean") return [];
+ if (typeof item.at !== "string" || item.at.length === 0) return [];
+ if (!isStoredCaller(item.by)) return [];
+ return [{ enabled: item.enabled, at: item.at, by: item.by }];
+ });
+}
+
+/**
+ * The history with one more change on the end.
+ *
+ * Append only, and the caller supplies the instant so a mutation stays a pure
+ * function of its inputs.
+ */
+export function appendConsentEntry(
+ history: readonly ExternalAgentConsentEntry[] | undefined,
+ enabled: boolean,
+ by: Caller,
+ at: string,
+): ExternalAgentConsentEntry[] {
+ return [...normalizeExternalAgentConsentHistory(history), { enabled, at, by }];
+}
+
+/**
+ * Has this project ever turned external audits on?
+ *
+ * Asked of the history rather than the boolean, because the boolean cannot tell
+ * "never granted" from "granted and withdrawn" — and those are different
+ * answers to the only question a reader has about a control that is off.
+ */
+export function consentWasEverGranted(
+ history: readonly ExternalAgentConsentEntry[] | undefined,
+ enabled: boolean,
+): boolean {
+ return enabled || normalizeExternalAgentConsentHistory(history).some((entry) => entry.enabled);
+}
+
+/**
+ * Was this reading collected under a permission that has since been withdrawn?
+ *
+ * Computed against the history, not against the boolean alone, and that is the
+ * whole point: a project that turned audits off, on again and off again has
+ * readings from two separate permitted stretches, and both predate the current
+ * withdrawal. A reading taken while consent stands is not stale at all, which
+ * is why an enabled project answers `false` before anything else is examined.
+ *
+ * An unparseable timestamp answers `false`. The clause is a claim about when
+ * something happened, and a date nobody can read is not evidence for it.
+ */
+export function readingPredatesWithdrawal(
+ history: readonly ExternalAgentConsentEntry[] | undefined,
+ enabled: boolean,
+ observedAt: string | undefined,
+): boolean {
+ if (enabled || !observedAt) return false;
+ const entries = normalizeExternalAgentConsentHistory(history);
+ const withdrawal = entries.filter((entry) => !entry.enabled).at(-1);
+ if (!withdrawal) return false;
+ const taken = Date.parse(observedAt);
+ const withdrawn = Date.parse(withdrawal.at);
+ if (!Number.isFinite(taken) || !Number.isFinite(withdrawn)) return false;
+ return taken < withdrawn;
+}
+
+/**
+ * The name a history line is attributed to.
+ *
+ * Total, and it can be: this is new storage, so no entry in it predates F4's
+ * split and none carries `UNKNOWN_USER` — the same reason `case-decisions.ts`
+ * gives for the log never reaching for `callerFromLegacyActor`. `normalize`
+ * drops an entry whose caller has no identity, so what reaches a screen always
+ * has one.
+ *
+ * `attributionOf` is not the right tool here and deliberately is not used: it
+ * withholds a name for a system caller, which is correct where the identity is
+ * a separate field beside a line that reads without it. C3's line does not read
+ * without it — "Connected by" needs somebody — so this resolves a name for
+ * either class instead of returning null for one of them.
+ */
+export function consentCallerName(by: StoredCaller): string {
+ return by.kind === "person" ? by.userId : by.agent;
+}
diff --git a/src/lib/mutations.ts b/src/lib/mutations.ts
index 47ccfcc..d1ad79f 100644
--- a/src/lib/mutations.ts
+++ b/src/lib/mutations.ts
@@ -15,6 +15,7 @@ import { defaultNewPageFlag, flagCapacityError } from "./watchCapacity";
import { applyWatchlistPageOrder, changePageFlagOrder, sortWatchlistPages } from "./watchlistOrder";
import { removeTaskMarker } from "./taskMarkers";
import { promoteAgentIssueToTask } from "./agentIssueTasks";
+import { appendConsentEntry } from "./agentConsent";
import type { AgentIssueCase } from "./agentIssueCases";
import { isKnownNativeElementId, normalizeNativeElementControls } from "./nativeElements";
import { narrowNativeElementExclusionReason } from "./nativeElements";
@@ -300,12 +301,34 @@ export function addAgentIssueTask(
}, dataStore);
}
+/**
+ * Change the project's consent, and record who changed it.
+ *
+ * The boolean is the live answer the gate reads; the history is the record of
+ * how it got there. They are written in one `withState` and there is no path
+ * that writes either alone — a flipped boolean with no entry would leave the
+ * project unable to say who permitted a scan, and an entry with no flip would
+ * describe a decision that never took effect.
+ *
+ * A call that does not change the value appends nothing. An entry says what was
+ * decided, and re-selecting the position a project is already in is not a
+ * decision; recording one would put a change in the history that never happened.
+ */
export function setExternalAgentAuditEnabled(
enabled: boolean,
+ by: Caller,
dataStore: DataStore = getStore(),
+ now: Date = new Date(),
): Promise {
return withState((state) => {
+ if (state.externalAgentAuditEnabled === enabled) return;
state.externalAgentAuditEnabled = enabled;
+ state.externalAgentAuditConsentHistory = appendConsentEntry(
+ state.externalAgentAuditConsentHistory,
+ enabled,
+ by,
+ now.toISOString(),
+ );
}, dataStore);
}
diff --git a/src/lib/settings-copy.ts b/src/lib/settings-copy.ts
index 0bea410..b5d6b52 100644
--- a/src/lib/settings-copy.ts
+++ b/src/lib/settings-copy.ts
@@ -128,6 +128,78 @@ export const SETTINGS_SYSTEM_CONTRIBUTES: Record = {
"Opens the page in a real browser and records what actually rendered, which is how a finding is confirmed on a page that needs scripts to run.",
};
+/* ── Consent, on the Ora row of Connected systems ───────────────────────── */
+
+/**
+ * The retention half of the Ora disclosure.
+ *
+ * DRAFT, PENDING LEGAL REVIEW. It is rendered, because a reader deciding today
+ * needs it more than the review needs to finish first, and it states a
+ * consequence about third-party publication rather than a fact about this app —
+ * so it is not reworded here. It has one home for that reason: a sentence under
+ * review that is inline in a screen is a sentence nobody can find when the
+ * review lands.
+ *
+ * It appends to the shipped Ora note rather than replacing any of it. S9
+ * rewrote that note in plain language and it survives here word for word; what
+ * it still did not say is what turning the switch back off does and does not
+ * undo, which is the question a withdrawal makes somebody ask.
+ */
+export const SETTINGS_CONSENT_RETENTION =
+ "Turning this off stops new scans but does not remove what has already been published.";
+
+/**
+ * The heading over the history.
+ *
+ * "Consent history", not "Connection history": the entries name the action
+ * somebody took and so use the control's words, but the heading names the
+ * RECORD, and `types.ts` is explicit that the boolean is a consent record and
+ * not a presentation flag. It is also the only place the word consent appears
+ * on screen.
+ */
+export const SETTINGS_CONSENT_HISTORY_LABEL = "Consent history";
+
+/** One change, in the control's own words. `name` is a caller's display name. */
+export function settingsConsentGranted(name: string): string {
+ return `Connected by ${name}`;
+}
+
+export function settingsConsentWithdrawn(name: string): string {
+ return `Disconnected by ${name}`;
+}
+
+/**
+ * The answer to "was this ever on?", which an empty list does not give.
+ *
+ * Rule 15's shape: nothing recorded is a real answer and the screen says it,
+ * rather than leaving a reader to infer it from a gap.
+ */
+export const SETTINGS_CONSENT_NEVER = "Ora has never been connected for this project.";
+
+/**
+ * The other empty state: connected, but from before there was a record of it.
+ *
+ * Rule 18. An absent record is not nothing to report — it is a project whose
+ * consent plainly was granted and whose grant has no date, and saying so is the
+ * only honest answer. Suppressing the block would treat the absence as a
+ * smaller fact than it is, and `consent.never` would be a flat lie about a
+ * project that is connected right now.
+ */
+export const SETTINGS_CONSENT_UNRECORDED =
+ "Ora was connected before this project kept a consent record, so there is no date for it.";
+
+/**
+ * What S4's ledger adds to a reading that predates the current withdrawal.
+ *
+ * Imported by the ledger row rather than restated there: one string, one home,
+ * two renderers being the thing rule 20 forbids. It names Ora rather than the
+ * permission in the abstract because it renders beside an Ora row, and it does
+ * not repeat that the scan was public — that is disclosed at the control, and
+ * this clause only has to say the permission behind the reading is gone.
+ */
+export const SETTINGS_CONSENT_STALE_READING =
+ "collected while Ora was connected, which it no longer is";
+
/* ── Appearance ─────────────────────────────────────────────────────────── */
export const SETTINGS_APPEARANCE_LABEL = "Appearance";
diff --git a/src/lib/store/normalize.ts b/src/lib/store/normalize.ts
index d05a732..f236d5e 100644
--- a/src/lib/store/normalize.ts
+++ b/src/lib/store/normalize.ts
@@ -9,6 +9,7 @@ import { normalizeWatchCapacity } from "../watchCapacity";
import { sortWatchlistPages } from "../watchlistOrder";
import { reconcileTaskMarkers } from "../taskMarkers";
import { normalizeNativeElementControls } from "../nativeElements";
+import { normalizeExternalAgentConsentHistory } from "../agentConsent";
import { normalizeAlertWebhookUrl } from "../webhook";
import { normalizeDigestRecipients } from "../digestRecipients";
@@ -61,6 +62,10 @@ export function normalizeState(state: AppState): AppState {
// Consent defaults closed: anything other than an explicit true means no
// external provider request is permitted for this project.
state.externalAgentAuditEnabled = state.externalAgentAuditEnabled === true;
+ // The record behind that boolean. Read whole and never pruned: an entry is
+ // dropped only when it is structurally not an entry at all.
+ state.externalAgentAuditConsentHistory =
+ normalizeExternalAgentConsentHistory(state.externalAgentAuditConsentHistory);
state.agentIgnoreDefaults = normalizeAgentIgnoreSettings(state.agentIgnoreDefaults);
normalizeSensitivitySettings(state);
state.digestCadence = normalizeDigestCadence(state.digestCadence);
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 274e55f..566b46a 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -268,10 +268,33 @@ export type CaseDecisionKind = "exclude" | "include" | "accept" | "dismiss";
* was ever written before the split — nothing here needs, or should reach for,
* `callerFromLegacyActor`.
*/
-export type CaseDecisionCaller =
+export type StoredCaller =
| { kind: "system"; agent: string }
| { kind: "person"; userId: string };
+/**
+ * The decision log's caller. An alias, not a copy: the consent history stores
+ * the same thing, and two spellings of one shape in one file would be the drift
+ * rule 20 names with none of the justification `AgentIssueCheckResult` has —
+ * that one is duplicated because its other half lives in a module this file may
+ * not import, and both halves of this one are right here.
+ */
+export type CaseDecisionCaller = StoredCaller;
+
+/**
+ * One recorded change of project consent for external agent audits.
+ *
+ * `enabled` is the value it changed TO, so an entry answers "what was decided"
+ * rather than "what changed", and a reader replaying the list never has to
+ * infer a state from a gap.
+ */
+export interface ExternalAgentConsentEntry {
+ enabled: boolean;
+ /** ISO. Also the entry's place in the history, which is kept in append order. */
+ at: string;
+ by: StoredCaller;
+}
+
export interface CaseDecisionRecord {
decision: CaseDecisionKind;
remediationKey: string;
@@ -883,6 +906,21 @@ export interface AppState {
* is false.
*/
externalAgentAuditEnabled?: boolean;
+ /**
+ * Every change to that consent, in the order they were made.
+ *
+ * The boolean above is the live answer; this is the record of how it got
+ * there, and the two are written together or not at all. Append-only, like
+ * `caseDecisions` and for the same reason: withdrawing consent is a decision
+ * somebody made, and a log that dropped the grant preceding it would leave
+ * the project unable to answer what was permitted when a stored reading was
+ * taken — which is the question a withdrawal makes somebody ask.
+ *
+ * Absent means no change was ever recorded, which is not the same as consent
+ * having been off all along by anyone's decision. The screen says so in as
+ * many words rather than rendering an empty list.
+ */
+ externalAgentAuditConsentHistory?: ExternalAgentConsentEntry[];
agentIgnoreDefaults?: AgentIgnoreSettings;
/**
* The one sensitivity control's position, as a plain string.