Per-release adversarial deltas
The threat model is a living document: a design change that adds a new trust boundary (a new integration, a new sync direction, a new client) updates it in the same pull request. The milestone adversarial passes — the #500 self-pass and the #502 independent review — are one-time events, though, so a release between two milestones could add a security-relevant surface (a client, an outbound integration, an authentication path, a persistence target) with no required delta review at all. This page and the gate over it (issue #953) close that gap.
The obligation
A final release that the gate detects as touching a security-relevant surface class records a delta here: one dated row answering “what new surface did this release add, and what covers it?”. A release that touched no recognised class needs no row (a row saying none is still welcome, and is what a surface-bearing release that turned out to add no new class should say).
The row names the class and points at the mechanism that catches the next instance of it (or the issue that will build that mechanism). This is the same question asvs-l2-verification-report.md §9 asks at the milestone, asked at every release instead.
How it is enforced
.github/workflows/release.yml runs backend/cmd/adversarialdelta as a mandatory gate on a final release, beside the ASVS re-verification row check. It feeds the command the paths changed since the previous release tag (git diff --name-only), classifies the surface classes those paths touch, and requires this page to hold a row for the release naming each class it touched:
| Surface class | Paths that trigger it |
|---|---|
route | anything under backend/routes/ |
persistence | anything under backend/database/migrations/ |
outbound-client | anything under backend/integrations/, or a backend/services/*_client.go file |
auth-path | the session-minting / authentication-precondition files enumerated in backend/internal/adversarialdelta |
The classification is deliberately coarse and over-inclusive: a false positive costs one reviewed row, while a false negative reproduces the gap this exists to close. These classes are not the fine-grained surface inventory — each of those already has its own mechanical owner (the authorization matrix, the outbound-client classification, the cascade-coverage test, the session-minting route gate; see §9 of the verification report). This gate is the per-release backstop for a new class none of them anticipated, and a release that added one should extend the table above in the same change.
Both this gate and the ASVS row check are skipped on an RC cut: an RC ships the same tree as the final, and neither obligation is about the candidate. The deferral target is promotion, so .github/workflows/promote-rc.yml runs both gates against the RC commit, before it pushes the final tag — otherwise a release cut as an RC and promoted (the normal path since v0.9.0) would never have either enforced. The gate logic lives once, in .github/scripts/release-obligations.sh, which both release.yml’s final path and promote-rc.yml call (issue #1195); the invariant that both invoke it is checked by cmd/releasegatecheck.
A release can be dispatched past the gate with a recorded acknowledgement reason (the same escape shape as the ASVS row gate); the reason is recorded in release-metadata.json for a direct final cut, or promotion-metadata.json for a promoted RC, not swallowed.
Ledger
Rows are newest-first. The Release cell is the release tag passed to the gate. Every surface-class token the gate detected in the release’s diff must appear in the row’s text, so this table is what the gate actually reads (and edits to it are reviewable in the release PR).
The ledger begins with the release that introduced it (issue #953). Releases before that are not reconstructed: the milestone adversarial passes (#500, #502) already reviewed the surface that existed then, and backfilling “none” rows nobody reviewed would be worse than an explicit empty start.
| Release | Date | Surface | Coverage |
|---|---|---|---|
| v1.5.1 | 2026-10-26 | auth-path, persistence, route | auth-path: #2010/#2036 add explicit API-token scopes and an interactive-session requirement for credential management, reject the account password for CardDAV/CalDAV once a second factor is enrolled, and make the scope-rollback migration fail closed; #2073 rejects tokens whose owner is soft-deleted, keeps a correct password from clearing the shared failure bucket before the second factor succeeds, and consumes password-reset tokens with one conditional write; #2076 gives the Android session an epoch so a late grant refresh, Local re-mint or profile switch cannot write one profile’s token against another origin. No new credential type and no new session-minting route. Covered by middleware/api_scope_test.go, middleware/auth_apitoken_test.go, carddav/auth_test.go, controllers/dav_password_policy_test.go, controllers/two_factor_controller_test.go, controllers/user_controller_test.go, routes/session_minting_route_gate_test.go, DefaultSessionManagerTest, SessionExpiryWiringTest, LocalSessionReminterTest and ProfileSwitchTokenIsolationTest; ASVS rows 2.2.1/2.5.6/3.5.2/4.2.1 and MASVS AUTH-4 carry the claims. persistence: migration 000078_app_password_scopes backfills api_tokens.scopes from the legacy scope and adds users.allow_dav_password without removing user data; the down migration refuses to widen scopes it cannot prove. Covered by database/migrate_app_password_scopes_test.go, the schema-parity and frozen-chain checks, and the PII inventory. route: no new public route; the authenticated surface gains RequireAPIScopeForMethod, RequireInteractiveSessionForCredentials, the MCP mcp-scope gate and PATCH /users/dav-password-policy (absent in embedded mode), all declared in the authorization matrix. Covered by backend/routes/authorization_matrix_test.go, authorization_matrix_credentials_test.go and authorization_matrix_mutations_test.go. Also reviewed (not a recognised class): #2072’s user-scoped contact projections and MCP/occasion CSV sensitivity filters (ASVS 1.5.1/1.8.2/13.1.6; contact_record_outward_census_test.go and the new user-scope tests), #2074’s REST UID guard and CardDAV local-only-field preservation, #2075’s removal of silent sync truncation plus idempotency/import-race fixes, #2016/#2026 ETag parsing, #1986’s undici/zod security bump, and the scanner/no-exec/license records in ASVS rows 2.36–2.41. |
| v1.5.0 | 2026-10-23 | route, persistence, auth-path | route: four additive endpoints, all declared in backend/routes/authorization_matrix_test.go — PATCH /api/v1/users/time-format (classProtected), PATCH /api/v1/webauthn/credentials/:id (classProtected), and PUT/DELETE /api/v1/contacts/:id/target-closeness (classItem, ownership-scoped; controllers/contact_target_closeness_controller_test.go#TestTargetCloseness_OwnershipAndMissing). The passkey rename is label-only and deliberately proof-free (a label is not a posture change); adding/removing a factor still needs a live proof. ?full=false on GET /graph is an additive query parameter (the default remains the documented full graph after #1934), and the dashboard’s *_total fields are additive. Covered by the authorization matrix, the conditional-write matrix (exemptions for auth routes and toggles), the DB-fault sweeps, and the embedded-route census. persistence: migrations 000074 (field_definitions.group_label, defaulted), 000075 (contacts.ambient, defaulted), 000076 (contacts.target_closeness nullable plus a verbatim reach_out_suggestions rebuild adding one enum value), and 000077 (users.time_format, defaulted). Each has a tested .down.sql (backend/database/migrate_field_definition_group_test.go, migrate_contact_ambient_test.go, migrate_target_closeness_test.go, migrate_user_time_format_test.go), preserves every existing row, and the rebuilt table is covered by the schema-parity and cascade-coverage tests. The #1738 hobby move is a ledgered startup backfill (no migration): moved rows are soft-deleted, and docs/security/data-retention-lifecycle.md records the lifecycle. auth-path: the time-format PR adds time_format to the login, 2FA, OIDC and device-grant responses only — all additive fields, no authentication decision, credential check or session-minting path changes; routes/session_minting_route_gate_test.go still pins the set. The passkey-rename route is an authenticated, ownership-scoped update of the caller’s own credential label with no secret returned. No JWT, clock, lockout or cookie code changed. outbound-client: none — no new client, destination or credential. Also recorded (not a recognised class): per-entry personalInfo sensitivity (#1738) is filtered inside backend/models/contact_record.go#RecordForContactFiltered for every outward projection, preserved on the inbound side by backend/models/contact_personal_info.go#MergePersonalInfoFromRemote, and pinned per surface by the CardDAV, export, share and subscription-reconcile tests; §9 of the verification report gained a row and #1935 tracks the mechanical projection gate. Android’s opt-in screenshot setting (#1741) is a device-local, default-off setting with no new data copy or trust boundary, pinned by ScreenResilienceTest/FlagSecurePolicyTest and recorded under MASVS STORAGE-9/P6. |
| v1.4.2 | 2026-10-14 | auth-path, outbound-client, route | Recorded ahead of the tag by the Test Hardening gate (issue #1497) over v1.4.1..main@2a4c04aa (first recorded as v1.5.0 at 3bd08216; the milestone shipped only fixes surfaced by test hardening, so the cut is the PATCH v1.4.2 per docs/versioning-policy.md); a later security-relevant change before the cut must extend this row. route: no new route and no authorization change — backend/routes/routes.go gains only clockMiddleware (installs clock.System{} unless a test pre-installed a fake, issue #1494); every other backend/routes/ change is a test (the DB-fault read and CRUD sweeps, the conditional-write and cursor-pagination matrices). Covered by backend/routes/authorization_matrix_test.go, which fails a route with no declared row. auth-path: hardening, no new way to obtain a session. (1) AuthMiddleware and AdminMiddleware answer every rejection with the standard error envelope instead of a flat {"error":"…"} string (#1631), and AdminMiddleware now reports a failed admin lookup as a 500 DATABASE_ERROR rather than masking an outage as 401 User not found — still fail-closed, the admin handler does not run (#1658, middleware/error_envelope_test.go#TestAdminMiddleware_LookupFailureIs500). (2) The JWT algorithm pin is unchanged and is now test-backed (middleware/auth_jwt_rejection_test.go: alg: none, RSA/HMAC confusion, wrong secret, malformed, the HMAC-family behavior; #1472), and token and lockout expiry decisions read the injected clock (middleware.ValidateTimeClaims, login_lockout.go, services/session_service.go; #1494). (3) POST /api/v1/password-reset/request is non-enumerating by status, body and headers even when the mail transport or token persist fails (#1473), by latency (mail send and token write run off the request path, #1554) and under repeat requests (one reset email per account per 2-minute cooldown, #1569); user_controller.go, two_factor_controller.go, oidc_controller.go, api_token_controller.go and device_grant_controller.go also move to the injected clock and the injected audit recorder (RecordAuditEvent(db, …), #1493) with no change to what is authenticated. The audit trail now records each child of a contact cascade and drops an event whose transaction rolled back (#1471, #1557, #1547). (4) Account deletion flushes the async audit recorder before the user row is hard-deleted (#1693, PR #1694): DeleteUser (controllers/admin_user_controller.go) and DeleteOwnAccount (controllers/user_controller.go) call flushAuditBeforeUserDelete (controllers/user_delete_cascade.go), and models/audit_recorder.go demotes an audit_events FK failure to a debug drop only when the user id was marked as deleting and the actor row is verifiably gone (auditActorGone); every other persist failure still warns and counts. The authorization guards, the cascade list and the delete semantics are untouched, no authentication decision changes, and the events dropped are exactly those the audit_events.user_id ON DELETE CASCADE FK would have removed with the user, so the retention rule is unchanged. Pinned by controllers/audit_user_delete_race_test.go#TestUserDeletion_FlushesQueuedAuditEvents and models/audit_recorder_deleted_user_test.go#TestAuditRecorder_EventForDeletingUserIsQuietlyDropped. #1692 (manual-gate attestation ledger and release-gate test fixes) touches no application surface. Covered by ASVS rows 2.5.1, 3.2.4, 7.1.3 and 1.1.6, routes/session_minting_route_gate_test.go for the session-minting set, and the middleware-auth and services-auth mutation legs (#1491, #1624). outbound-client: no new client, destination or credential. geocoder_client.go and immich_client.go read the injected clock; paperless_client.go’s Test-connection probes were corrected to the endpoints real Paperless-ngx serves (GET /api/documents/?page_size=1&fields=id, GET /api/ui_settings/) after the real-server contract tests (#1490) showed the old ones could never succeed — redirects are still not followed and the token stays origin-bound. Covered by backend/integrations/matrix_test.go#TestEveryOutboundClientIsClassified and integrations/realserver/paperless_test.go. |
| v1.4.1 | 2026-10-06 | auth-path, outbound-client, route | Backfilled 2026-10-14 (the release shipped on a recorded ack_adversarial_delta; the real review is ASVS/MASVS changelog row 2.28 over v1.4.0..v1.4.1, and the weekly dry run rehearses v1.4.1 against v1.4.1..HEAD and so warned about the missing row). route: the stateless draft-geocode lookup beside the per-address route (#1465), same ownership check and sensitivity gate, writes nothing — covered by backend/routes/authorization_matrix_test.go. auth-path: POST /api/v1/password-reset/request made non-enumerating when mail or persist fails (#1473), plus no-cover markers on two unreachable AuthMiddleware arms; no new way to obtain a session (backend/routes/session_minting_route_gate_test.go). outbound-client: no new client; GeoPulse, Immich, Paperless, Seafile and WebDAV clients refuse redirects and stored secrets are origin-bound (#1501, #1509), the geocoder cache is per-user and its rate gate sheds (#1470) — covered by backend/integrations/matrix_test.go#TestEveryOutboundClientIsClassified. |
| v1.4.0 | 2026-10-05 | outbound-client, persistence, route | route: the contact-map API — the bulk GET /api/v1/contacts/map read (#1427), the per-address POST /api/v1/contacts/:id/addresses/:addressId/geocode action and the unauthenticated GET /api/v1/config/map style bootstrap (#694, ADR 0031) — plus the MCP server’s new inbound POST /mcp surface (#176, ADR 0032). Covered by §9’s route/inbound-surface rows: backend/routes/authorization_matrix_test.go fails a new REST route with no matrix row, backend/routes/embedded_route_census_test.go asserts the embedded mode omits the network surfaces, and MCP is pinned by controllers/mcp_controller_test.go. persistence: migrations 000071_address_ids (stable per-address IDs, address Sensitivity, Coordinates), 000072_geopulse_configs, and 000073_address_coordinates_backfill — covered by §9’s entity/table row (issue #611): models/schema_parity_test.go pins the migrated schema, controllers/delete_cascade_coverage_test.go sweeps the new tables, and docs/security/data-retention-lifecycle.md §26 records the coordinate copies. outbound-client: the SSRF-guarded geocoder (backend/services/geocoder_client.go) and the GeoPulse on-demand client (backend/services/geopulse_client.go) — covered by §9’s outbound-HTTP-client row: the semgrep unguarded-dialer rule plus INT-01’s TestEveryOutboundClientIsClassified / TestSSRFClaimsMatchSource and the INT-02 faults.Hook request seam (services/integration_fault_seam.go). No new surface class — the map read is the third documented owner-view sensitivity exception (asvs-l2.md 1.5.1); the milestone-gate record is asvs-l2-verification-report.md §10 row 2.26 (issue #1118). |
| v1.0.0 | 2026-09-21 | route | The v0.9.0 → v1.0.0 diff changed only test files under backend/routes/ (the authorization, credential, ownership, conditional-write, idempotency and session matrices) — no new route. The route class is deliberately path-based and over-inclusive. Covered by backend/routes/authorization_matrix_test.go and the session-minting route gate; backfilled here because the gate was skipped for this RC-derived final (issue #1195). |
When the gate reports a touched surface class, add a row like:
| vX.Y.Z | YYYY-MM-DD | route, auth-path | What the release added and the mechanism or issue that covers the next instance. |