diff --git a/REPAIRS.md b/REPAIRS.md index d8d0198..5a199ca 100644 --- a/REPAIRS.md +++ b/REPAIRS.md @@ -72,6 +72,43 @@ one commit. the absence of a caller is a missing surface, not proof the concept is gone. Whoever takes this should decide which it is rather than assuming. +### 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. + +### The Ora note runs two sentences together on screen + +- **Found by:** C3, on `chunk-c3`, while merging `origin/main` (`07834fa`). +- **Not claimed, and deliberately not fixed here.** It is a one-character change + in a line C3 also edits, which is exactly the shape R3 warns about: a shared + defect buried in a feature diff cannot be reviewed or reverted on its own. +- **Symptom:** in `settings/page.tsx`, `{SETTINGS_SYSTEM_CONTRIBUTES.ora}` is + followed by ` Switching it on sends...` on the same line, and JSX drops that + leading space. The rendered note reads + "...you have to switch on.Switching it on sends...", with the DOM showing + `switch on.Switching`. Introduced by S9 (#93); no check reads rendered + copy, so CI is green on it. +- **Scope:** the only `{expr} Text` pair in that file, and the file uses `{" "}` + nowhere, so this is a one-off rather than a pattern. +- **Fix:** `{SETTINGS_SYSTEM_CONTRIBUTES.ora}{" "}` — or move the following word + onto its own line, which is what makes JSX keep the gap. Worth a look at S9's + other screens for the same pair before closing it. + ## Landed ### Conflict markers committed into `DECISIONS.md` diff --git a/src/app/(app)/pages/[id]/page.tsx b/src/app/(app)/pages/[id]/page.tsx index 04d35c5..8b99b72 100644 --- a/src/app/(app)/pages/[id]/page.tsx +++ b/src/app/(app)/pages/[id]/page.tsx @@ -965,7 +965,14 @@ function ReadingsSection({ /> } > - +
+

{SETTINGS_CONSENT_HISTORY_LABEL}

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

+ {everGranted ? SETTINGS_CONSENT_UNRECORDED : 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); @@ -492,24 +554,45 @@ function ConnectedSystemsGroup({ disabled }: { disabled: boolean }) { -
-
-

{EVIDENCE_SOURCE_LABEL.ora}

-

- {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. + */} +
+
+
+

{EVIDENCE_SOURCE_LABEL.ora}

+

+ {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.{" "} + {/* 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 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.