Plan: A8b — reporting least-privilege restricted DB role + credential cutover (#1456, epic &73)

On this page
NOTE

Child of ADR-004 reporting PHI tenancy (Step 2b). Implements the ADR-004 Amendment 1 A8 "least-privilege restricted DB role" storage control. Sibling of #1256 (A8a sealing — Done 2026-08-11). Not fleet-first: the owner/app split, fail-closed role reconcile, and SECURITY-DEFINER-only delete path all ship today in the chain-v2 substrate (canopy-security / canopy-medicaid / canopy-tanf) — this plan adopts that pattern for reporting’s ADR-004 tenancy.

Status

Step Description Status

0

File the fleet prerequisites (#1463 idempotency-DDL relocation, #1464 KEK preflight in the migrate job) + blocked-by links; commit this plan + nav; amend the parent Step-2b row; refresh #1456 ACs.

Done (2026-08-12) — this MR

1

#1463 (P1, fleet) — relocate the idempotency_keys runtime DDL into BOTH migration arms (in-process migration_phase + cargo xtask migrate apply), so a no-CREATE runtime role can boot.

Done (2026-08-12) — #1463

2

P2 (rides #1456) — additive CANOPY_{SVC}__MIGRATION_DATABASE_URL bootstrap support (migration pool credential, falls back to the runtime URL).

Done (2026-08-12) — #1456 (this MR)

3

#1464 (P3, fleet) — KEK preflight in cargo xtask migrate apply for the sealing-service set (closes the JobOwned strand-hole).

Done (2026-08-12) — #1464

4

Roles migration — canopy_reporting_owner (NOLOGIN) + canopy_reporting_app (LOGIN), fail-closed attribute reconcile.

Done (2026-08-12) — #1456 (this MR)

5

Ownership transfer + grant-matrix migration — catalog-driven ALTER … OWNER loop, REVOKE ALL FROM PUBLIC, the enumerated per-object grants.

Done (2026-08-12) — #1456 (this MR)

6

SECURITY DEFINER reporting_janitor_reap — the runtime loses direct DELETE on the six generation-scoped tables.

Done (2026-08-12) — #1456 (this MR)

7

Runtime role-attribute boot guard (CANOPY_REPORTING__ALLOW_BROAD_DB_ROLE override).

Done (2026-08-12) — #1456 (this MR)

8

Devstack cutover — roles in init.sql, runtime URL → canopy_reporting_app, migrations as owner; documented destructive volume reset.

Done (2026-08-12) — #1456 (this MR)

9

Real-login tests — grant matrix, immutability, guard rejection/override, battery/e2e as the restricted login.

Done (2026-08-12) — #1456 (this MR)

10

Docs — cutover/rollback runbook, the per-migration ownership-transfer standing convention, configuration reference, parent-plan closeout.

Done (2026-08-12) — #1456 (this MR)

Epic: &73
Issues: #1456 (this plan), #1463 (P1), #1464 (P3)
Branch: feature/1456-reporting-least-privilege-role (implementation; this plan rode feature/1456-a8b-canonical-plan)

Context

ADR-004 Amendment 1 A8 mandates that reporting’s restricted holdings — the sealed T-MSIS extract (#1256), its per-generation DEKs, the run substrate — be owned by a least-privilege restricted DB role, not the shared broad canopy role every devstack service connects as today (docker-compose.yml:919). canopy-reporting currently runs ONE DB role by design (services/canopy-reporting/migrations/20261101000001_report_runs.sql:38); with A8a sealing shipped, this role split is the last unshipped A8 storage control.

The fleet already ships this architecture. The chain-v2 substrate (services/canopy-security/migrations/20260910000000_chain_v2_substrate.sql, mirrored in canopy-medicaid/canopy-tanf) delivers the NOLOGIN owner + app split (:32, :52), a fail-closed reconcile refusing over-privileged roles (:59-73), create-then-transfer ownership (GRANT <owner> TO current_user :119; ALTER … OWNER TO :828-835), and REVOKE … FROM PUBLIC + an explicit per-object grant matrix (:838-876). 20260930000000_chain_append_staging.sql is the exact shape for this plan’s janitor: SELECT/INSERT granted (:72), column-restricted UPDATE (:80), no DELETE grant (:82), the only delete path a SECURITY DEFINER fn (:92-104). And services/canopy-caps/migrations/20260622000000_create_determination_snapshots.sql:35-37 records the load-bearing caveat that shapes everything here: a table-level REVOKE does not bind the table’s owner — immutability-by-grant-omission requires the runtime NOT to own the tables, which is why ownership transfers to a NOLOGIN role the runtime is not a member of.

Two fleet-wide "don’ts" from that precedent bind this plan (and corrected the parent plan’s original wording): no ALTER DEFAULT PRIVILEGES (zero occurrences fleet-wide; chain-v2 substrate — "explicit per-object v1-surface grants, never ALTER DEFAULT PRIVILEGES`"), and no `SET ROLE (it persists and would run sqlx’s _sqlx_migrations bookkeeping as the owner; handoff is ALTER OWNER + membership).

One discovery makes this bigger than reporting: idempotency_keys is the fleet’s only runtime DDL — created by app code on the runtime pool at crates/canopy-api/src/lib.rs:173 (IdempotencyCache::with_pool; DDL at idempotency.rs:337-409), defined in no .sql migration. Any service adopting a no-CREATE runtime role fails startup there first. That relocation (#1463) and the migrate-job KEK preflight (#1464 — the JobOwned path can currently run reporting’s destructive sealing migration with no KEK, stranding an unsealable service, despite 20261110000001:46-48 claiming otherwise) are fleet-shared canopy-api/xtask prerequisites, filed as their own blocking issues.

Scope

In scope (#1456):

  • canopy_reporting_owner (NOLOGIN) + canopy_reporting_app (LOGIN) with fail-closed attribute reconcile.

  • One-time ownership transfer (catalog-driven ALTER … OWNER loop) + the per-object grant matrix (REVOKE ALL FROM PUBLIC).

  • SECURITY DEFINER reporting_janitor_reap — published-snapshot immutability by grant omission.

  • Runtime role-attribute boot guard with the CANOPY_REPORTING__ALLOW_BROAD_DB_ROLE accountable override.

  • Devstack real-login cutover; credential-cutover + rollback runbook; real-login grant-matrix/immutability/guard tests.

  • P2: additive CANOPY_{SVC}__MIGRATION_DATABASE_URL bootstrap support (fleet-shared but additive/harmless-until-used, so it rides this issue).

Out of scope:

  • #1463 (P1) idempotency_keys DDL relocation and #1464 (P3) migrate-job KEK preflight — separate fleet blocking issues (independently shippable; this plan’s Steps 1/3 describe them for context).

  • #1303 orphan per-generation DEK reclamation (the janitor never touches redaction_keys).

  • #1459 report_run_universe.aux residual; other services' least-privilege adoption (they reuse this pattern later).

Design

Role + credential architecture

  • canopy_reporting_ownerNOLOGIN; owns every reporting schema object after transfer. canopy_reporting_appLOGIN; the runtime connection identity. Passwords never in source (Kerckhoffs): the dev app password is set in devstack init.sql; prod passwords are secret-managed. Role creation is DO IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '…') -guarded (Postgres has no CREATE ROLE IF NOT EXISTS).

  • Ownership transfer — the pre-existing-object subtlety (differs from chain-v2). chain-v2 only transfers objects it creates in the same migration (it owns them at creation). Reporting’s ~14 tables + 8 functions — the 7 report_run_* fns plus the canopy_redaction_keys_one_way_guard() trigger fn (20261110000000_reporting_redaction_keys.sql:44) — already exist, owned by whoever historically applied migrations (devstack: canopy). (Corrected 2026-08-12, probe-proven: the originally ratified REASSIGN OWNED BY <old-owner> FAILS in the devstack — canopy is the pinned bootstrap superuser, and Postgres refuses REASSIGN OWNED for pinned roles: "cannot reassign ownership of objects owned by role canopy because they are required by the database system".) The cutover therefore uses a catalog-driven DO loopALTER … OWNER TO canopy_reporting_owner for every relation in the schema (pg_class, relkinds r/p/v/m/S) and every function (pg_proc) — complete by construction (cannot miss the trigger fn), and working for any old owner including pinned superusers. Prereq before any transfer: GRANT USAGE, CREATE ON SCHEMA <schema> TO canopy_reporting_owner (clone 20260910000000_chain_v2_substrate.sql:120-123; without CREATE ON SCHEMA, ownership transfer and subsequent object creation fail under a non-superuser migrator).

  • Migrator identity. ALTER … OWNER requires the executor to own the object (or be a superuser) and to hold SET-membership in the new owner role. The one-time cutover migration therefore runs as the current object-owner / a superuser (devstack: canopy via MIGRATION_DATABASE_URL; prod: the historical migrator identity or a superuser — runbook), after GRANT canopy_reporting_owner TO current_user. Every subsequent reporting migration runs as the ongoing migrator (a CREATEROLE non-superuser that is a member of canopy_reporting_owner): it creates objects as current_user, then ALTER … OWNER TO canopy_reporting_owner + grants to app. This per-migration transfer is a standing convention (Step 10) — the first migration that skips it silently regresses to migrator-owned objects with the app missing grants. The NOLOGIN owner never logs in; "owner = migrator identity" in #1456’s prose means objects are owned by the owner role, not that the owner is a login.

  • Fail-closed attribute reconcile in the roles migration: RAISE EXCEPTION if either role carries rolsuper / rolcreatedb / rolcreaterole / rolreplication / rolbypassrls (or the owner is rolcanlogin) — clone 20260910000000_chain_v2_substrate.sql:59-73.

Grant matrix (enumerated from real runtime SQL — per-object, no ALTER DEFAULT PRIVILEGES)

REVOKE ALL ON ALL TABLES/FUNCTIONS IN SCHEMA … FROM PUBLIC; GRANT CONNECT ON DATABASE; GRANT USAGE, CREATE ON SCHEMA … TO canopy_reporting_owner (transfer prereq) + GRANT USAGE ON SCHEMA … TO canopy_reporting_app; pin ALTER ROLE canopy_reporting_app SET search_path. No sequence grants exist to make — every PK is a UUID (uuidv7()/gen_random_uuid()); no serial/sequence/nextval anywhere in the reporting migrations.

Table S I U D Evidence / note

snap_monthly_reports (fns_388)

INS store.rs:89; no runtime UPDATE (submission_status read-only in this service); DELETE only via the janitor fn

snap_qc_universe (qc_7176)

INS store.rs:211; keyset reads; DELETE only via the janitor fn

tanf_acf199_snapshots (acf_199)

INS store.rs:559; WPR/ACF-196 count reads; DELETE only via the janitor fn

medicaid_tmsis_eligibility_extracts (sealed)

INS store.rs:691 (sealed payload); never UPDATEd — the AAD binds each ciphertext to its row’s natural keys precisely so re-attribution fails; DELETE only via the janitor fn

medicaid_cms416_reports (cms_416)

INS store.rs:736; DELETE only via the janitor fn

medicaid_cms64_reports (derived)

INSERT … ON CONFLICT DO NOTHING (reporting/medicaid.rs:194); no generation_id ⇒ not janitored

tanf_acf196_reports (derived)

INSERT … ON CONFLICT DO NOTHING (reporting/tanf.rs:211)

tanf_wpr_calculations (derived)

UPSERT ON CONFLICT (report_month) DO UPDATE (reporting/tanf.rs:107) ⇒ needs UPDATE

report_generations

permanent provenance — never runtime-DELETEd; UPDATEs: pins, supersede, publish, the #1462 abandons

report_runs

DML almost entirely via the SECURITY-INVOKER fns; DELETE = report_run_reap only (7-day floor)

report_run_universe

INS store/universe.rs:49; seq-keyset reads; DELETE only via the janitor fn

redaction_keys

get-or-create INSERT + live-DEK SELECT (store/restricted.rs:99,174); the BEFORE trigger already hard-rejects DELETE/UPDATE; one-way shred lifecycle is #1303

event_outbox

the shared OutboxDrainer (spawned unconditionally, bootstrap.rs:216) SELECTs/claims/parks/janitors; INSERT grant-later — reporting emits no events yet (src/events.rs skeleton)

event_inbox

no grant — no subscriber registered, no InboxDrainer spawned; the table exists prophylactically

GRANT EXECUTE on the 7 report_run_* plpgsql fns (enqueue/claim/checkpoint/heartbeat/abandon/finalize/reap). Load-bearing: all seven are SECURITY INVOKER — their bodies run as the caller, so EXECUTE alone is insufficient; the app also needs the underlying report_runs/report_generations DML enumerated above (which it has). Built-ins (uuidv7, gen_random_uuid, pg_advisory_xact_lock) are PUBLIC-executable by default. The canopy_redaction_keys_one_way_guard() trigger fn needs no app EXECUTE (trigger firing bypasses EXECUTE checks) but must transfer ownership with everything else.

Published-snapshot immutability — SECURITY DEFINER reporting_janitor_reap

Today the generation janitor is a Rust loop of unguarded direct DELETEs (worker/mod.rs:324-337 over JANITOR_TABLES — the 5 output tables + report_run_universe) trusting a candidate list from a separate SELECT (janitor_candidates, mod.rs:498-529). Under least privilege the runtime holds no direct DELETE on those six tables; the delete moves behind a SECURITY DEFINER fn owned by canopy_reporting_owner:

  • reporting_janitor_reap(p_generation_id uuid, p_retention_days int) RETURNS (output_rows bigint, universe_rows bigint), SET search_path = <schema>, pg_temp; REVOKE EXECUTE FROM PUBLIC + GRANT EXECUTE TO canopy_reporting_app (the chain_staging_dequeue shape, 20260930000000:92-104).

  • Re-verifies eligibility inside the function — the immutability guard must not trust the caller: SELECT state … FOR SHARE; proceed only if abandoned past created_at + retention OR superseded with a qualifying successor past retention (replicate mod.rs:505-512); a staged/published generation is unreapable by construction (return 0). Then the six DELETE FROM <t> WHERE generation_id = p_generation_id, tallying via GET DIAGNOSTICS.

  • The FKs to report_generations have no ON DELETE CASCADE and generation rows are permanent, so the fn deletes each child table explicitly. janitor_candidates stays as the SELECT-only batch selector; the Rust loop calls SELECT * FROM reporting_janitor_reap($1, $2) per candidate. report_run_reap (already a guarded SQL fn) is kept as-is. Preserve ReapSummary.output_rows_deleted / universe_rows_deleted.

Runtime boot guard (the one genuinely-new layer)

No runtime DB-role guard exists anywhere in the fleet (the rejection logic ships only migration-side, in the chain-v2 reconcile). Clone the #1006 guard structure from services/canopy-applications/src/guard.rs:

  • RolePrivilegeVerdict { DevAllowed, OverrideAllowed, Refused }; a pure evaluate(env: &str, is_overbroad: bool, allow_broad: bool) → Verdict (no I/O — the whole decision table unit-tested); fail-closed resolve_env (unset CANOPY_ENVproduction).

  • A thin require_least_privilege_role(pool, allow_broad) wrapper runs the SQL probe outside the pure fn — SELECT rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls FROM pg_roles WHERE rolname = current_user plus a canopy_reporting_app-membership check (pg_has_role) — passes the boolean in, tracing::warn!`s the auditable line on `OverrideAllowed (naming the runbook), and Err`s on `Refused naming the hazard + the CANOPY_REPORTING__ALLOW_BROAD_DB_ROLE override. (pg_roles is world-readable — a non-superuser can probe its own attributes; only rolpassword is masked.)

  • Timing: after the runtime pool is built (bootstrap.rs:121) and before serving; the elevated migration pool has already closed (bootstrap.rs:309), so the probe reads the runtime credential. Config: allow_broad_db_role: bool on the reporting config.

Steps

Step 0: File the fleet prerequisites

Files: none (GitLab + this plan)

Done (2026-08-12, this MR): #1463 (P1) + #1464 (P3) filed with full ACs; blocks links to #1456 set; parent Step-2b row amended; #1456 ACs refreshed.

Step 1: #1463 (P1, fleet) — relocate idempotency_keys DDL to the migration path (both arms)

Files: crates/canopy-api/src/idempotency.rs, crates/canopy-api/src/bootstrap.rs, crates/canopy-api/src/lib.rs, xtask/src/cmd/migrate.rs

Delivered under #1463 (see the issue for full ACs). Extract the DDL (CREATE_TABLE_DDL + EXPAND_MIGRATION_DDL, idempotency.rs:337-409) into a standalone fn; split IdempotencyCache::with_pool (:550-644) into that DDL part + a runtime part (metrics + cleanup task + renewal pool) that assumes the table exists; lib.rs:173 becomes DDL-free. The DDL fn is invoked from both migration arms: (a) bootstrap.rs migration_phase on the migration pool before it closes (:296-311), and (b) xtask/src/cmd/migrate.rs::apply after migrator.run (:200-205) — arm (b) is load-bearing because under SKIP_MIGRATIONS/JobOwned, migration_phase no-ops (bootstrap.rs:289-295); the chain-migration-split compose profile (dormant until the #1279 cutover, docker-compose.yml:1676-1728) models canopy-security/tanf/medicaid exactly that way whenever invoked, and reporting joins them at Step 8. The idempotency.rs:544-549 "no sqlx::migrate!`" constraint is respected — the DDL runs on the migration pool/job, never inside `_sqlx_migrations.

Step 2: P2 — additive CANOPY_{SVC}__MIGRATION_DATABASE_URL

Files: crates/canopy-common/src/settings.rs, crates/canopy-api/src/bootstrap.rs

Add migration_database_url: Option<String> beside database_url (settings.rs:19), read through the secret provider with the optional-secret pattern (settings.rs:222-229). bootstrap.rs:119 runs migration_phase against migration_database_url.as_deref().unwrap_or(&database_url); the runtime pool (:121) stays on database_url. Run validate_database_name + the TLS gate on the effective migration URL as well. Fallback-to-runtime-URL is the correct default for the ~18 services that do not split credentials; a mis-set reporting deployment fails loud (permission denied on the first DDL), not silent.

Step 3: #1464 (P3, fleet) — KEK preflight in the migrate job

Files: xtask/src/cmd/migrate.rs

Delivered under #1464 (see the issue for full ACs). In migrate.rs::apply, before any pool opens: canopy_crypto_shred::require_kek("CANOPY_ENCRYPTION_KEY")? gated on the sealing-service set {reporting, persons, snap, tanf, medicaid, caps, wic}. Closes the JobOwned hole the sealing migration’s 20261110000001:46-48 comment wrongly assumed shut. (Deviation from the original step: that migration comment itself is NOT byte-edited — sqlx checksums applied migrations, so any edit breaks every existing database’s version validation. Post-#1464 the comment’s conclusion — "this reset cannot commit and then strand an unsealable service" — is actually true via both gates; the corrected two-gate claim lives in the preflight’s own comment in migrate.rs.)

Step 4: Roles migration

Files: services/canopy-reporting/migrations/<ts>_least_privilege_roles.sql

DO-guarded CREATE ROLE canopy_reporting_owner NOLOGIN + CREATE ROLE canopy_reporting_app LOGIN (no password in source); the fail-closed attribute reconcile (clone 20260910000000_chain_v2_substrate.sql:59-73); GRANT canopy_reporting_owner TO current_user (the migrator’s SET-membership for the transfer).

Step 5: Ownership transfer + grant matrix

Files: same migration as Step 4, or a sibling <ts>_least_privilege_grants.sql (ordering: roles → schema grants → transfer → object grants)

GRANT USAGE, CREATE ON SCHEMA … TO canopy_reporting_owner; the one-time catalog-driven ownership-transfer loop (pg_class relkinds r/p/v/m/S + pg_proc, schema-scoped, ALTER … OWNER TO canopy_reporting_owner — the ratified REASSIGN OWNED fails on the pinned devstack superuser, see Design); REVOKE ALL … FROM PUBLIC; the per-object grant matrix + fn EXECUTEs from the Design table; GRANT USAGE ON SCHEMA … TO canopy_reporting_app; ALTER ROLE canopy_reporting_app SET search_path.

Step 6: SECURITY DEFINER janitor

Files: services/canopy-reporting/migrations/<ts>_janitor_security_definer.sql, services/canopy-reporting/src/worker/mod.rs

Create reporting_janitor_reap per the Design; refactor reap_and_janitor (worker/mod.rs:310-339) to call it per candidate; the app role receives no direct DELETE on the six tables (grant omission in Step 5 + this fn is the only path).

Step 7: Runtime boot guard

Files: services/canopy-reporting/src/guard.rs (new), services/canopy-reporting/src/config.rs, services/canopy-reporting/src/main.rs

Clone services/canopy-applications/src/guard.rs per the Design. Call site: after bootstrap() returns the runtime pool, before serving.

Step 8: Devstack cutover

Files: devstack/postgres/init.sql, docker-compose.yml

Roles + dev app password in init.sql behind DO-block guards (created as devstack superuser canopy). Init scripts run only on empty PGDATA, so adoption requires a one-time cargo xtask dev destructive volume reset (already blessed pre-1.0 by the parent’s fresh-start reset) — documented; no new post-start provisioning path. Flip docker-compose.yml:919 to postgres://canopy_reporting_app:…@postgres:5432/canopy_reporting; migrations run as owner via CANOPY_REPORTING__MIGRATION_DATABASE_URL (Step 2) or a canopy-reporting-migrate one-shot modeled on the chain-migration-split services (docker-compose.yml:1690-1704; note that profile is dormant until #1279 and its rehearsal carries the #1372 depends_on caveat — verify both when wiring the one-shot).

Step 9: Real-login tests

Files: services/canopy-reporting/tests/least_privilege_role_test.rs (new), e2e config

Grant-matrix proofs over a dedicated canopy_reporting_app connection (no pooled SET ROLE): the app runs every service flow; cannot UPDATE extracts; cannot DELETE outputs directly; cannot DDL; cannot reap a staged/published generation through reporting_janitor_reap. Guard rejection: superuser and broad-membership sessions refused outside development without the override; the override WARNs and proceeds. The full battery + e2e run reporting AS the restricted login (Step 8 makes this the devstack default). Mirror services/canopy-tanf/tests/chain_v2_substrate_test.rs:1220-1242.

Step 10: Docs + closeout

Files: docs/modules/ROOT/pages/security-operations.adoc, configuration-reference.adoc, deployment-guide.adoc, coding-conventions.adoc (overlay), the parent plan, CHANGELOG.adoc

Credential-cutover + rollback runbook (the one-time transfer-as-current-owner/superuser note; the ongoing CREATEROLE non-superuser migrator; app-password provisioning). The standing convention: every future reporting migration must ALTER … OWNER TO canopy_reporting_owner + grant the app its new objects — recorded in the coding-conventions overlay, not just prose, else the first unaware migration silently regresses privileges. Configuration reference: MIGRATION_DATABASE_URL, ALLOW_BROAD_DB_ROLE. Parent Step-2b row → Done; this plan → Archive.

Files Touched

File Change

docs/modules/ROOT/pages/plans/reporting-least-privilege-role-a8b.adoc, docs/modules/ROOT/nav.adoc

this plan + its nav entry (Scale Readiness, epic &73) — this MR

docs/modules/ROOT/pages/plans/archive/scale-audit-adr004-reporting-phi-tenancy.adoc

Step-2b row → Blocked(#1463/#1464) + child pointer; "default ACLs pinned" → enumerate-per-object — this MR

crates/canopy-api/src/{idempotency,bootstrap,lib}.rs, xtask/src/cmd/migrate.rs

#1463 (P1) + #1464 (P3) + P2 — the fleet prerequisites

services/canopy-reporting/migrations/<ts>_*.sql (×2–3)

roles + reconcile; REASSIGN + grant matrix; SECURITY DEFINER janitor

services/canopy-reporting/src/{guard.rs,config.rs,main.rs,worker/mod.rs}

boot guard; janitor call-path refactor

devstack/postgres/init.sql, docker-compose.yml

role provisioning; runtime-URL cutover; migrate identity

services/canopy-reporting/tests/least_privilege_role_test.rs

real-login grant-matrix / immutability / guard battery

Verification

  1. cargo xtask plan-lint + cargo xtask check-docs clean; the Antora build resolves every xref (this MR).

  2. Implementation MRs: cargo nextest run -p canopy-reporting green as canopy_reporting_app (the devstack default after Step 8).

  3. The Step-9 negative proofs: UPDATE extract → permission denied; direct DELETE output → permission denied; DDL → permission denied; reporting_janitor_reap on a published generation → 0 rows / refusal.

  4. Guard: superuser session outside development → boot refused naming the override; with CANOPY_REPORTING__ALLOW_BROAD_DB_ROLE=true → boots with the WARN line.

  5. Full pre-push battery + cargo xtask e2e green with reporting on the restricted login end-to-end.

Documentation Updates

  • Antora canonical docs — security-operations.adoc (cutover/rollback runbook), configuration-reference.adoc (MIGRATION_DATABASE_URL, ALLOW_BROAD_DB_ROLE), deployment-guide.adoc (role provisioning), the coding-conventions overlay (the per-migration ownership-transfer standing convention).

  • CHANGELOG.adoc — entry under == Unreleased (implementation MRs).

  • Parent plan Step-2b row kept current at each step; this plan → Archive + nav update on completion.

Edit this page · default