fix(cv): enforce readable render palettes
CI and Deploy / test (pull_request) Successful in 4m37s
CI and Deploy / deploy (pull_request) Has been skipped

Choose a contrasting header foreground for custom accents and keep public colour and font overrides inside safe supported values.
This commit is contained in:
cesnimda
2026-08-15 17:08:20 +02:00
parent f0b9b222ff
commit 3b86ea2da0
8 changed files with 98 additions and 18 deletions
+30
View File
@@ -210,6 +210,36 @@ public sealed class CvBuilderTests
Assert.DoesNotContain("transform:scale", html);
}
[Fact]
public void Header_band_and_sidebar_contact_text_own_their_contrasting_palette()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var modern = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern" }).Html;
var lightAccent = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern", AccentColor = "#f8fafc" }).Html;
var technicalTheme = CvThemeCatalog.Resolve("technical");
var technical = renderer.Render(model, technicalTheme, new CvVariantSettings { ThemeId = "technical" }).Html;
Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#fff;}", modern);
Assert.Contains(".header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{color:#000;}", lightAccent);
Assert.Contains($".sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{technicalTheme.SidebarInk};}}", technical);
}
[Fact]
public void Variant_settings_reject_css_injection_in_visual_overrides()
{
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings
{
AccentColor = "#fff;} </style><script>alert(1)</script>",
HeadingFont = "Arial;}</style><script>alert(2)</script>",
BodyFont = "'Segoe UI', Roboto, Arial, sans-serif",
});
Assert.Null(settings.AccentColor);
Assert.Null(settings.HeadingFont);
Assert.Equal("'Segoe UI', Roboto, Arial, sans-serif", settings.BodyFont);
}
[Fact]
public void Accent_override_reaches_the_css()
{
+29
View File
@@ -63,6 +63,16 @@ public sealed class CvCustomSectionSetting
public static class CvVariantSettingsJson
{
private static readonly HashSet<string> AllowedFonts = new(StringComparer.Ordinal)
{
"'Segoe UI', Roboto, Arial, sans-serif",
"Arial, Helvetica, sans-serif",
"Georgia, 'Times New Roman', serif",
"'Helvetica Neue', Arial, sans-serif",
"'Roboto', Arial, sans-serif",
"'Poppins', 'Segoe UI', Arial, sans-serif",
};
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
@@ -83,9 +93,28 @@ public static class CvVariantSettingsJson
{
s ??= new CvVariantSettings();
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
s.AccentColor = NormalizeColor(s.AccentColor);
s.HeadingFont = NormalizeFont(s.HeadingFont);
s.BodyFont = NormalizeFont(s.BodyFont);
s.Sections ??= new();
s.Overrides ??= new();
s.CustomSections ??= new();
return s;
}
private static string? NormalizeColor(string? value)
{
var candidate = value?.Trim();
return candidate is { Length: 7 }
&& candidate[0] == '#'
&& candidate.Skip(1).All(Uri.IsHexDigit)
? candidate.ToLowerInvariant()
: null;
}
private static string? NormalizeFont(string? value)
{
var candidate = value?.Trim();
return candidate is not null && AllowedFonts.Contains(candidate) ? candidate : null;
}
}
+21 -4
View File
@@ -22,6 +22,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
{
settings = CvVariantSettingsJson.Normalize(settings);
var accent = Override(settings.AccentColor, theme.Accent);
var headerInk = ContrastInk(accent);
var headingColor = theme.HeadingColor ?? accent;
var headingFont = Override(settings.HeadingFont, theme.HeadingFont);
var bodyFont = Override(settings.BodyFont, theme.BodyFont);
@@ -35,7 +36,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
? SplitColumns(model, theme, showIcons)
: (string.Empty, RenderSections(model.Sections, theme));
var css = BuildCss(theme, accent, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
var css = BuildCss(theme, accent, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
var header = RenderHeader(model, theme, showIcons, twoColumn);
var body = theme.Layout switch
{
@@ -189,7 +190,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return s;
}
private static string BuildCss(CvTheme t, string accent, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn)
private static string BuildCss(CvTheme t, string accent, string headerInk, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn)
{
var margin = F(t.PageMarginMm * density);
var sectionGap = F(t.SectionGapMm * density);
@@ -209,13 +210,14 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}}
.sidebar .section-title{{color:{t.SidebarInk};border-color:rgba(255,255,255,.35);}}
.sidebar .tag{{border-color:rgba(255,255,255,.4);}}
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{t.SidebarInk};}}
.sidebar a{{color:inherit;}}
.main{{padding:{margin}mm;}}
.hero .name{{color:{t.SidebarInk};}}"
: $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}}
.header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}}
.header-band{{background:{accent};color:#fff;}}
.header-band .name,.header-band .headline,.header-band a{{color:#fff;}}
.header-band{{background:{accent};color:{headerInk};}}
.header-band .name,.header-band .headline,.header-band .contact,.header-band .contact-item,.header-band a{{color:{headerInk};}}
.header-centered{{flex-direction:column;text-align:center;justify-content:center;}}
.header-centered .contact{{justify-content:center;}}
.header-plain{{border-bottom:2px solid {accent};}}";
@@ -293,6 +295,21 @@ h1,h2{{font-family:{headingFont};}}
$@"<svg viewBox=""0 0 16 16"" fill=""none"" stroke=""currentColor"" stroke-width=""1.3"" stroke-linecap=""round"" stroke-linejoin=""round""><path d=""{path}""/></svg>";
private static string Override(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
private static string ContrastInk(string background)
{
if (background.Length != 7 || background[0] != '#' || !background.Skip(1).All(Uri.IsHexDigit)) return "#000";
var r = Convert.ToInt32(background.Substring(1, 2), 16) / 255d;
var g = Convert.ToInt32(background.Substring(3, 2), 16) / 255d;
var b = Convert.ToInt32(background.Substring(5, 2), 16) / 255d;
static double Channel(double value) => value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4);
var luminance = 0.2126 * Channel(r) + 0.7152 * Channel(g) + 0.0722 * Channel(b);
var whiteContrast = 1.05 / (luminance + 0.05);
var blackContrast = (luminance + 0.05) / 0.05;
return whiteContrast >= blackContrast ? "#fff" : "#000";
}
private static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
private static string Enc(string? v) => WebUtility.HtmlEncode(v ?? string.Empty);
private static string Attr(string? v) => WebUtility.HtmlEncode(v ?? string.Empty).Replace("'", "&#39;", StringComparison.Ordinal);
+1
View File
@@ -199,3 +199,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-165 | CV renderer/resolver/template tests; Builder helper/editor/list Jest; optimized frontend build; real headless Chromium DOM/PDF probe; diff check | Repository root / `job-tracker-ui` | Verify professional multi-page CV rendering, physical preview metrics, custom-section ordering and stored-output safety under pathological content | PASS — backend 25/25; frontend 21/21; build passes. A 14-role/75-skill fixture with oversized name/email/URL produced zero horizontal offenders and a 9-page 173,196-byte PDF with extractable final-page text. Custom sections share persisted order; partial pages use ceiling count; stale preview/save/export races are gated | Synthetic data and local Chromium only; authenticated 375/768/1440 app journey, real private CV, production browser binary and DOCX remain unverified | Renderer/PDF repository scope verified; application-browser and production gates remain |
| V-166 | Focused/full backend and frontend tests; optimized frontend build/TypeScript; diff review | Repository root / `job-tracker-ui` | Verify an administrator can identify the deployed application version without exposing build metadata in the normal-user UI/API bootstrap | PASS — focused Auth/System 36/36 and AppShell 2/2; backend 640/640; frontend 53 suites/221 tests; production build passes. Configured version/commit reaches admin `/api/auth/me`, normal users receive no configured metadata, and the responsive header badge is absent unless the admin-owned prop is supplied | Local/JSDOM evidence only; remote CI and deployed version comparison remain. Jest retains the documented force-exit/open-handle notice | Priority repository increment verified; ship proof remains |
| V-167 | Failing Career round-trip reproduction; affected/full backend; focused Career/Profile Jest; persistence-consumer and diff review | Repository root / `job-tracker-ui` | Preserve every reviewed Career text value across save/get/version/projection/import use without weakening extraction cleanup or write bounds | PASS — pre-fix location became `Oslo, Norway and` and the test failed; post-fix affected backend 112/112, backend 642/642 and Career/Profile UI 17/17 pass. Website path/query, remote location, free-form date, custom language and incomplete WIP entry round-trip; oversized website is rejected explicitly | Local SQLite/JSDOM only; no real private CV/model/provider/production data. One initial Jest command used nonexistent paths and was corrected; the correct files passed | Reviewed/extracted normalization boundary verified locally |
| V-168 | Failing renderer contrast test; focused/full backend; CV Builder/public Jest; real Chromium computed-style/overflow probe; pathological A4 PDF export and text inspection | Repository root / `job-tracker-ui` | Make header/sidebar contact text readable for theme and custom palettes while keeping public renderer settings inert | PASS — renderer/settings 25/25, backend 644/644 and CV UI 22/22. Chromium computed white on Modern blue, black on `#f8fafc`, white on Technical sidebar, with zero element overflow. A 14-role/75-skill fixture produced a 17-page 259,447-byte A4 PDF with 1,685 final-page characters. CSS-like accent/font payloads normalize to null | Synthetic local data/browser only; authenticated application journey and production browser binary remain unverified. A direct PowerShell assembly probe failed to load dependencies before the compiled temporary test probe passed; temporary proof cleanup was blocked by execution policy | Renderer contrast/public-setting boundary verified locally |
+4 -1
View File
@@ -31,6 +31,8 @@ Status: `IMPLEMENTED — NOT VERIFIED`. Repository, automated and pathological C
- Autosaves are serialized; stale preview responses are ignored; export/public actions save pending edits first.
- CV deletion and version restore use the shared application dialog system.
- The non-functional page-number switch is no longer advertised; `ShowPageNumbers` remains a backward-compatible exporter extension point until the Chromium CLI path supports controlled PDF footers.
- Header-band contact/headline text now owns a computed black-or-white foreground chosen for the stronger WCAG contrast against both theme and custom accents. Sidebar contact/headline text owns `SidebarInk` instead of inheriting the main-page muted colour.
- Accent overrides are restricted to six-digit hex colours and font overrides to the six editor-supported stacks before CSS generation, preventing malformed/public settings from escaping the generated stylesheet.
## Save-integrity increment
@@ -51,10 +53,11 @@ Status: `IMPLEMENTED — NOT VERIFIED`. Repository, automated and pathological C
## Verification to date
- Focused Builder list/helper/deep-link/save/navigation: 3 suites, 21/21 tests; editor deep-link/interaction 9/9.
- Focused renderer/template backend: 25/25 tests.
- Focused renderer/template backend: 25/25 tests, including header/sidebar contrast ownership and visual-override sanitization.
- Full frontend: 49/49 suites, 184/184 tests.
- Production build/TypeScript and `git diff --check`: pass.
- Pathological Chromium render: 14 long roles, 75 long skills, oversized name/email/URL, zero horizontal-overflow elements, nine-page PDF (173,196 bytes) with extractable final-page content.
- Contrast rerun in real Chromium: Modern default computed white on `rgb(37, 99, 235)`, light custom accent computed black on `rgb(248, 250, 252)`, Technical sidebar computed white on `rgb(15, 76, 92)`, and all three reported zero element overflow at a 1400px viewport. The harder 14-role/75-skill fixture exported a 17-page A4 PDF (259,447 bytes) with 1,685 extractable characters on the final page.
- Implementation commits: `b58cc19`, `a5b74e0`, `2043349` plus the V-165 checkpoint.
## Product research
+2 -2
View File
@@ -10,9 +10,9 @@ Updated: 2026-08-15
- **Production-verified work:** None.
- **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Real provider, SMTP/MariaDB and production environments are unavailable; DEP-001 awaits approved merge/live verification. The in-app browser is available for local UI checks.
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
- **Immediate order:** CV contact contrast; JOBS-002 parity/regression; cross-app contrast/accessibility; PRODUCT-001; VER-001. The admin version indicator is pushed in `a6cffe0`; Career lossless manual persistence is locally complete. External-only work remains skipped, not allowed to stall this queue.
- **Immediate order:** JOBS-002 parity/regression; cross-app contrast/accessibility; PRODUCT-001; VER-001. Admin version (`a6cffe0`), Career lossless persistence (`f0b9b22`) and CV contact contrast are locally complete. External-only work remains skipped, not allowed to stall this queue.
- **Status counts:** 7 `VERIFIED LOCALLY`; 22 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 4 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
- **Test status:** backend 642/642; affected Career/import/profile/job paths 112/112; Career/Profile UI 17/17; prior frontend 53/53 suites and 221/221 tests plus optimized production build/TypeScript pass. The version indicator also has focused Auth/System 36/36 and AppShell 2/2 evidence. Prior AI sidecar 22/22, Playwright 6/6, pathological nine-page Chromium/PDF proof and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
- **Test status:** backend 644/644; CV renderer/settings 25/25; CV Builder/public UI 22/22; affected Career/import/profile/job paths 112/112; Career/Profile UI 17/17; prior frontend 53/53 suites and 221/221 tests plus optimized production build/TypeScript pass. Real Chromium contrast/overflow checks pass and the harder pathological fixture exports a 17-page A4 PDF with final-page text. Prior AI sidecar 22/22, Playwright 6/6 and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
- **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred.
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap.
+4 -4
View File
@@ -56,8 +56,8 @@ This queue records the highest-value work that can proceed without production cr
|---:|---|---|---|
| 1 | Admin-only deployed-version indicator in the application header | DEP-001, VER-001 | Implemented with authenticated API and shell tests. The badge shows the CI deployment version and exposes the commit SHA in its accessible label/tooltip only for administrators; full regression, remote CI and deployment smoke remain. |
| 2 | Lossless Career field persistence | CAREER-001 | Implemented and locally verified. Manual website/location/contact/date/language values now use a reviewed-data persistence boundary; extraction heuristics remain isolated to extraction. Full remote/production smoke remains. |
| 3 | CV contact/header/sidebar contrast correction | CAREER-002 | Next implementable correction. Apply renderer-scoped theme ownership for contact/headline text and rerun pathological HTML/PDF proof without shrinking typography. |
| 4 | Dedicated Job Details parity and JOBS-002 closure | JOBS-002 | In progress. Finish any remaining legacy follow-up/application-package parity, dirty-edit behavior, tenant authorization and 375/768/1440 theme/keyboard/history/error/long-data verification. |
| 3 | CV contact/header/sidebar contrast correction | CAREER-002 | Implemented and locally verified. Header/custom-accent and sidebar palettes own readable foregrounds; real Chromium computed-style/overflow checks and a 17-page A4 PDF proof pass. |
| 4 | Dedicated Job Details parity and JOBS-002 closure | JOBS-002 | Next implementable package and in progress. Finish any remaining legacy follow-up/application-package parity, dirty-edit behavior, tenant authorization and 375/768/1440 theme/keyboard/history/error/long-data verification. |
| 5 | Cross-application contrast/accessibility pass | UX-002, UX-003, VER-001 | Queued after the scoped Career/CV corrections. Audit semantic alerts, secondary text, focus, loading/empty/error states and remaining hardcoded colors before documenting larger redesigns. |
| 6 | Honest Free/Pro homepage and upgrade surfaces | PRODUCT-001 | Not started. Inventory existing claims first; do not invent pricing, limits or trial terms before billing configuration is real. |
| 7 | Complete application action matrix and full regression | VER-001 | Not started. Populate incrementally, then run the complete backend/frontend/sidecar/E2E gates and accurately classify external production/provider checks. |
@@ -648,9 +648,9 @@ This queue records the highest-value work that can proceed without production cr
- **Required production verification:** existing variants/edit/export/public render smoke.
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** authenticated JobTracker three-width/theme/keyboard checks and production synthetic-variant smoke require their runtime environments.
- **Evidence:** `docs/verification/career-002-cv-builder.md`; V-120V-125 and V-165. Builder 21/21 and renderer/templates 25/25 pass plus build. Pathological Chromium output has zero horizontal offenders and produces a readable nine-page PDF without text shrinking.
- **Evidence:** `docs/verification/career-002-cv-builder.md`; V-120V-125, V-165 and V-168. Builder/public UI 22/22 and renderer/templates 25/25 pass. Real Chromium confirms contrast ownership and zero overflow for default/light custom/sidebar palettes; a harder pathological fixture produces a readable 17-page A4 PDF with final-page text and no text shrinking.
- **Commit:** `b58cc19`, `a5b74e0`, `2043349` plus the V-165 checkpoint.
- **Remaining work:** correct header/sidebar contact contrast, then complete authenticated application-browser/production gates and the honest deployed DOCX capability check. Public competitor-pattern research is complete; no authenticated competitor session is claimed.
- **Remaining work:** complete authenticated application-browser/production gates and the honest deployed DOCX capability check. Public competitor-pattern research is complete; no authenticated competitor session is claimed.
### MAIL-001 — Consolidated job-email hub and explicit sending
+7 -7
View File
@@ -2,17 +2,17 @@
Updated: 2026-08-15
- **Exact current task:** commit/push lossless Career manual-field persistence, then correct CV header/sidebar contact contrast before returning to JOBS-002 closure.
- **Last completed step:** separated strict extraction normalization from lossless reviewed-profile persistence and routed Career save/version/restore/import-merge/legacy consumers through the correct boundary.
- **Files currently modified:** structured profile persistence/validation, Career and CV/job projection consumers, Career/import regressions, architecture and work-programme evidence.
- **Commands already run:** failing Career round-trip reproduction; affected backend 112/112; full backend 642/642; Career/Profile UI 17/17; diff check.
- **Test results:** all corrected commands pass. One initial Jest invocation named two nonexistent test paths and returned “No tests found”; the correct Career/Profile files then passed 17/17. Jest retains the documented force-exit/open-handle notice.
- **Exact current task:** commit/push the CV contrast/public-render hardening increment, then continue JOBS-002 parity and regression closure.
- **Last completed step:** fixed header/sidebar contact contrast, added automatic custom-accent foreground selection, and restricted public renderer colour/font overrides to safe supported values.
- **Files currently modified:** CV settings normalization, themed renderer, renderer regressions and CAREER-002/work-programme evidence.
- **Commands already run:** failing renderer contrast reproduction; renderer/settings 25/25; full backend 644/644; CV Builder/public UI 22/22; real Chromium computed-style/overflow and 17-page PDF proof; diff check.
- **Test results:** all repository tests listed above pass. Chromium computed expected white/dark/white foregrounds for Modern/default, Modern/light override and Technical/sidebar, with zero overflow; the 259,447-byte A4 PDF has 17 pages and extractable final-page text. Jest retains the documented force-exit/open-handle notice.
- **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed.
- **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed.
- **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred.
- **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred.
- **Uncommitted changes:** V-167 Career reviewed-value persistence and tracking update; no dependency/schema/config/migration change. V-166 is committed/pushed as `a6cffe0`.
- **Uncommitted changes:** V-168 CV contrast and renderer-setting hardening; no dependency/schema/config/migration change. V-166/V-167 are pushed as `a6cffe0`/`f0b9b22`.
- **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007.
- **Exact next action:** review, commit and push V-167; then fix CV contact/header/sidebar contrast with renderer and PDF regressions.
- **Exact next action:** review, commit and push V-168; then trace the remaining legacy-vs-dedicated Job Details feature parity and close the highest-value JOBS-002 gap.
- **Work that can continue independently:** the immediate queue in the master plan: Career lossless persistence, CV contrast, JOBS-002 closure, cross-app contrast/accessibility, PRODUCT-001 and VER-001. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates.
- **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved.