From 913cb2e4210f9d7a40172da6958a20f6da544936 Mon Sep 17 00:00:00 2001 From: Matthew P Munger Date: Wed, 26 Aug 2026 19:18:48 -0500 Subject: [PATCH 1/3] C3: consent keeps a record, on the Ora row that already had the switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base sha 95d77a9, green on lint, typecheck, test (1335) and build. Item 1 was withdrawn before building: a consent control already existed inside Connected systems, so a sixth group would have put two controls and two public-scan disclosures over one boolean — the duplication this chunk exists to avoid. The Ora row IS the control. Its heading, note and inline "Connected" / "Not connected" are untouched. What it gains: the retention sentence its disclosure did not say, rendered above the control because that is what somebody needs before deciding; and beneath it, an append-only history of who changed the setting and when. The boolean and the history are written in one `withState` and there is no path that writes either alone. A call that changes nothing appends nothing — an entry says what was decided, and re-selecting the position a project is already in is not a decision. The gate is untouched: the isolation test, the refresh route and the consent-defaults-closed line in normalize are byte-identical to base. Withdrawal is not a retraction. Every stored reading stays present, legible and in place; a reading taken before the current withdrawal gains one clause, which the ledger imports from the settings module rather than restating. The clause is computed against the history, not the boolean, because a project that connected, disconnected and connected again has readings on both sides of the question. The brief's "no src/ path outside Settings" cannot hold alongside its own item 3: an append-only history is storage, so types, normalize and mutations necessarily move. Outside those and Settings, the only change is the one ledger clause and the prop that feeds it. Co-Authored-By: Claude Opus 5 --- src/app/(app)/pages/[id]/page.tsx | 9 +- src/app/(app)/settings/page.tsx | 107 +++++++-- src/app/api/settings/agent-audits/route.ts | 10 +- src/app/globals.css | 55 +++++ src/components/agent-access.tsx | 39 +++- src/lib/__tests__/agent-consent.test.ts | 252 +++++++++++++++++++++ src/lib/agentConsent.ts | 131 +++++++++++ src/lib/mutations.ts | 23 ++ src/lib/settings-copy.ts | 60 +++++ src/lib/store/normalize.ts | 5 + src/lib/types.ts | 40 +++- 11 files changed, 711 insertions(+), 20 deletions(-) create mode 100644 src/lib/__tests__/agent-consent.test.ts create mode 100644 src/lib/agentConsent.ts diff --git a/src/app/(app)/pages/[id]/page.tsx b/src/app/(app)/pages/[id]/page.tsx index b264237..2d853ec 100644 --- a/src/app/(app)/pages/[id]/page.tsx +++ b/src/app/(app)/pages/[id]/page.tsx @@ -964,7 +964,14 @@ function ReadingsSection({ /> } > - +
+

{SETTINGS_CONSENT_HISTORY_LABEL}

+ {entries.length === 0 ? ( +

{SETTINGS_CONSENT_NEVER}

+ ) : ( +
    + {entries.map((entry, index) => ( +
  • + + {entry.enabled + ? settingsConsentGranted(consentCallerName(entry.by)) + : settingsConsentWithdrawn(consentCallerName(entry.by))} + + {/* Absolute, never "3 days ago": a consent record is evidence + about a moment, and a relative date stops being true. */} + {formatDate(entry.at)} +
  • + ))} +
+ )} +
+ ); +} + function ConnectedSystemsGroup({ disabled }: { disabled: boolean }) { const { pathFor, alertWebhookUrl, updateAlertWebhookUrl, externalAgentAuditEnabled, + externalAgentAuditConsentHistory, setExternalAgentAuditEnabled, } = useStore(); + const consentOn = externalAgentAuditEnabled === true; + const consentHistory = externalAgentAuditConsentHistory ?? []; const stored = alertWebhookUrl ?? ""; const [webhookDraft, setWebhookDraft] = useState(stored); const [syncedFrom, setSyncedFrom] = useState(stored); @@ -448,24 +508,39 @@ function ConnectedSystemsGroup({ disabled }: { disabled: boolean }) { syncUrl={pathFor("/api/settings/webflow/sync")} /> + {/* + 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. + */}
-
-

Ora

-

- An independent, origin-level agent-readiness audit. Enabling it sends the production origin of each - watched page to Ora, whose scans are public: the result enters Ora's directory and is readable by - anyone. Webflow staging domains are never sent. -

+
+
+

Ora

+

+ An independent, origin-level agent-readiness audit. Enabling it sends the production origin of each + watched page to Ora, whose scans are public: the result enters Ora's directory and is readable by + anyone. Webflow staging domains are never sent.{" "} + {/* Draft, pending legal review. Rendered rather than withheld: a + reader deciding today needs it more than the review needs to + land first. Not reworded here — it states a consequence about + third-party publication. */} + {SETTINGS_CONSENT_RETENTION} +

+
+ setExternalAgentAuditEnabled(next === "connected")} + />
- setExternalAgentAuditEnabled(next === "connected")} - /> +
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 bd10a6a..e97b4b6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1042,6 +1042,56 @@ button:disabled { line-height: 1.5; } +/* ── 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 { @@ -2561,6 +2611,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 0e2b322..ab3d843 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..9a75b86 --- /dev/null +++ b/src/lib/__tests__/agent-consent.test.ts @@ -0,0 +1,252 @@ +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 type { AppState, 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_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("writes the consent boolean from exactly one place", () => { + // 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 3eac8d1..2955ea8 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 { EXCLUSION_REASONS, type ExclusionReason } from "./vocabulary"; @@ -298,12 +299,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 d78023e..e3a7ef6 100644 --- a/src/lib/settings-copy.ts +++ b/src/lib/settings-copy.ts @@ -77,6 +77,66 @@ export const SETTINGS_SYSTEMS_LABEL = "Connected systems"; export const SETTINGS_SYSTEMS_HELP = "Each one speaks for itself in the evidence ledger. Readings are never combined."; +/* ── 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. That note + * already says the scan is public and enters Ora's directory; what it 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."; + +/** + * 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. From bc9f32a52fe61c568252a4d3f3b11b9eb02858f7 Mon Sep 17 00:00:00 2001 From: Matthew P Munger Date: Wed, 26 Aug 2026 19:18:48 -0500 Subject: [PATCH 2/3] R3: record the misattached retention comment, and one non-finding Found while reading for C3, at base 95d77a9. Not claimed and not fixed here. Also records that C3's other reported leftover, addAgentIssueTask, is not one: it has two live consumers and writes into state.recs. Noted so the next session does not repeat the search. Co-Authored-By: Claude Opus 5 --- REPAIRS.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/REPAIRS.md b/REPAIRS.md index d202854..4332a3c 100644 --- a/REPAIRS.md +++ b/REPAIRS.md @@ -53,7 +53,32 @@ one commit. ## Open -_Nothing open._ +### The retention doc-comment in `mutations.ts` sits above the wrong function + +- **Found by:** C3, on `chunk-c3`, at base `95d77a9`. +- **Not claimed, and deliberately not fixed here.** Moving a comment is a + one-line change, and a one-line change in somebody else's function is still a + change C3 did not come to write. R3 keeps it out of the feature diff. +- **Symptom:** the comment reading "Withdrawing consent stops future requests; + evidence already stored is retained" sits immediately above a SECOND comment + and then `addAgentIssueTask`, which has nothing to do with consent. + `setExternalAgentAuditEnabled`, which the claim is about, is declared after + both and now carries a doc-comment of its own about the history it writes. +- **The behaviour claim is true** — withdrawal stops future requests and stored + evidence is retained, and C3 asserts both. Only its placement is wrong, so + this is a comment move and not a behaviour change. +- **Care needed:** the fix is to move the retention sentence onto + `setExternalAgentAuditEnabled` and merge it with the doc-comment C3 added + there, not to copy it — two statements of one retention rule is the drift that + put it in the wrong place to begin with. + +### C3's other reported leftover is not one + +C3's brief reports `addAgentIssueTask` as "a leftover producer with no +consumer". It is not, at `95d77a9`: `src/app/api/pages/[id]/agent-issues/route.ts` +calls it, `src/components/store.tsx` exposes it on the store, and +`promoteAgentIssueToTask` writes into `state.recs`, which is very much alive. +Recorded here so the next session does not spend the search a second time. ## Landed From bc689502e4b16e215488fe9d282fd17ef3f28013 Mon Sep 17 00:00:00 2001 From: Matthew P Munger Date: Wed, 26 Aug 2026 20:55:00 -0500 Subject: [PATCH 3/3] C3: a second empty state, and stack the card so the record sits under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections after review and after actually looking at the screen. `consent.unrecorded` (approved, seventh string): a project connected before this record existed has a grant with no date. Suppressing the block treated that absence as nothing to report, which is rule 18 exactly. Two empty states now get two lines — "never connected" would be a flat lie about a project that is connected right now. The Ora card is stacked. `.settings-system` is a flex row, so the history was rendering as a third column BESIDE the toggle rather than beneath it. Every structural test passed while it did; only opening the page showed it. Guarded now, using S8's existing `--stacked` modifier with the original row reproduced inside, so the Ora row itself is unchanged. Verified against a running app: the route appends with the verified identity and not the request body, re-selecting the current position appends nothing, a withdrawal keeps the grant, and both lines render with absolute dates beneath the control. The ledger clause could not be exercised live — the demo seed stores no Ora reading — so it rests on its unit tests, which assert both sides. Dropped the REPAIRS note about addAgentIssueTask: the claim was withdrawn from the C3 doc, so the correction has no subject left. Co-Authored-By: Claude Opus 5 --- REPAIRS.md | 8 ------- src/app/(app)/settings/page.tsx | 29 ++++++++++++++++--------- src/lib/__tests__/agent-consent.test.ts | 28 ++++++++++++++++++++++++ src/lib/settings-copy.ts | 12 ++++++++++ 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/REPAIRS.md b/REPAIRS.md index 4332a3c..bbb5c1d 100644 --- a/REPAIRS.md +++ b/REPAIRS.md @@ -72,14 +72,6 @@ one commit. there, not to copy it — two statements of one retention rule is the drift that put it in the wrong place to begin with. -### C3's other reported leftover is not one - -C3's brief reports `addAgentIssueTask` as "a leftover producer with no -consumer". It is not, at `95d77a9`: `src/app/api/pages/[id]/agent-issues/route.ts` -calls it, `src/components/store.tsx` exposes it on the store, and -`promoteAgentIssueToTask` writes into `state.recs`, which is very much alive. -Recorded here so the next session does not spend the search a second time. - ## Landed ### Conflict markers committed into `DECISIONS.md` diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 0ac5cd1..8dd09fa 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -32,6 +32,7 @@ import { SETTINGS_CONSENT_HISTORY_LABEL, SETTINGS_CONSENT_NEVER, SETTINGS_CONSENT_RETENTION, + SETTINGS_CONSENT_UNRECORDED, SETTINGS_DIGEST_HELP, SETTINGS_DIGEST_LABEL, SETTINGS_DIGEST_RECIPIENTS_EMPTY, @@ -433,13 +434,13 @@ function ExcludedGroup({ disabled }: { disabled: boolean }) { * so nothing here folds, counts or collapses entries — a project that connected * and disconnected four times has eight lines, because that is what happened. * - * The three states are distinct on purpose. Entries, so they render. No entries - * and never connected, which is a real answer and gets said in as many words - * rather than shown as an empty list. And no entries while connected — a - * project that turned Ora on before this record existed — where the honest - * thing is to show nothing at all: the control above already says Connected, - * and "has never been connected" would be a false statement about a project - * that plainly has been. + * The three states are distinct on purpose, and none of them is a blank. Entries, + * so they render. No entries and never connected, which is a real answer and + * gets said in as many words. And no entries while connected — a project that + * turned Ora on before this record existed — which is not nothing to report: + * it is a grant with no date, and rule 18 says an absent measurement is not a + * small one. Two empty states, two different lines, because "never connected" + * would be a flat lie about a project that is connected right now. */ function ConsentHistory({ entries, @@ -449,12 +450,13 @@ function ConsentHistory({ on: boolean; }) { const everGranted = consentWasEverGranted(entries, on); - if (entries.length === 0 && everGranted) return null; return (

{SETTINGS_CONSENT_HISTORY_LABEL}

{entries.length === 0 ? ( -

{SETTINGS_CONSENT_NEVER}

+

+ {everGranted ? SETTINGS_CONSENT_UNRECORDED : SETTINGS_CONSENT_NEVER} +

) : (
    {entries.map((entry, index) => ( @@ -515,7 +517,14 @@ function ConnectedSystemsGroup({ disabled }: { disabled: boolean }) { 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. + */} +

    Ora

    diff --git a/src/lib/__tests__/agent-consent.test.ts b/src/lib/__tests__/agent-consent.test.ts index 9a75b86..5522fa4 100644 --- a/src/lib/__tests__/agent-consent.test.ts +++ b/src/lib/__tests__/agent-consent.test.ts @@ -13,6 +13,7 @@ import { normalizeExternalAgentConsentHistory, readingPredatesWithdrawal, } from "../agentConsent"; +import { SETTINGS_CONSENT_NEVER, SETTINGS_CONSENT_UNRECORDED } from "../settings-copy"; import type { AppState, ExternalAgentConsentEntry } from "../types"; import type { Caller } from "../caller"; @@ -223,6 +224,7 @@ describe("one string, one home", () => { for (const constant of [ "SETTINGS_CONSENT_HISTORY_LABEL", "SETTINGS_CONSENT_NEVER", + "SETTINGS_CONSENT_UNRECORDED", "SETTINGS_CONSENT_RETENTION", "settingsConsentGranted", "settingsConsentWithdrawn", @@ -233,6 +235,32 @@ describe("one string, one home", () => { .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"); diff --git a/src/lib/settings-copy.ts b/src/lib/settings-copy.ts index e3a7ef6..6671494 100644 --- a/src/lib/settings-copy.ts +++ b/src/lib/settings-copy.ts @@ -125,6 +125,18 @@ export function settingsConsentWithdrawn(name: string): string { */ 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. *