Parent: #1930
What to build
GET /v1/consent/{deployment_id}/admin-consent completes the verb set — the Admin Panel can show consent state without mutating (grant and withdraw were the only observers before). Gated exactly like the writers: hasExplicitAdminAccess before any resolution (fail-closed on unconfigured admin rules; a per-request key can never call it), 404 on unknown deployment, and a non-declaring app is a legitimate {"consented":false} answer — reads inform, the writers' 400 is a semantic-error guard.
The stored record becomes a ConsentGrant envelope — the approved snapshot plus server-stamped provenance (grantedBy from the authenticated admin context, grantedAt from the injectable clock) — same record, same key, same ADMIN_CONSENT type, no new storage. The provenance sits outside the client-echoed Consent document, so nothing stampable rides the GET→POST round-trip and the content-binding compare is untouched.
Tri-state semantics, load-bearing:
consented: true — a record exists AND its snapshot equals the current declaration: live right now, exactly what the request-time gate enforces; the GET and the resolver share one describeAdminConsent implementation and cannot drift.
consented: false, stale: true — a grant exists but the declaration changed since; the last approval's provenance and snapshot stay visible for the panel's re-approve view; nothing resolves at runtime.
consented: false (rest absent) — withdrawn or never granted.
The user-consent path (acceptConsent, buildConsent, verifyUserConsent, USER_CONSENT storage) shows zero diff — its who/when are structural.
Also folded into this story (the story ships as five commits):
- Legacy-record leniency (security review):
ConsentGrant ignores unknown properties (the stored-DTO convention), so a record written by a pre-envelope commit — a bare Consent body at the same key — deserializes with consent == null and flows into the already-built fail-closed path (stale, nothing resolves, withdrawal works) instead of 400-ing every user call and 500-ing the GET.
- Single-sourced gate predicate (code review):
isAdminConsented calls one zero-allocation matches(stored, declaration) predicate rather than delegating through the presentation DTO — no DTO on the per-request hot path, gate semantics can never inherit presentation-driven changes; the provenance-non-participation test now re-stubs with a different grantedBy/grantedAt and asserts the yes stands.
- OpenAPI handling (docs pair): the tri-state contract is stated in the annotation response description and JavaDoc; a hand-edit of the committed spec was added then deliberately reverted — the committed OpenAPI spec stays purely generator-owned. Pipeline gotcha flagged for the team: because
description is a MANUAL_PREFERRED_FIELD, changing an annotation's response description never propagates to a node that already carries one.
Acceptance criteria
Blocked by
#1935
Parent: #1930
What to build
GET /v1/consent/{deployment_id}/admin-consentcompletes the verb set — the Admin Panel can show consent state without mutating (grant and withdraw were the only observers before). Gated exactly like the writers:hasExplicitAdminAccessbefore any resolution (fail-closed on unconfigured admin rules; a per-request key can never call it), 404 on unknown deployment, and a non-declaring app is a legitimate{"consented":false}answer — reads inform, the writers' 400 is a semantic-error guard.The stored record becomes a
ConsentGrantenvelope — the approved snapshot plus server-stamped provenance (grantedByfrom the authenticated admin context,grantedAtfrom the injectable clock) — same record, same key, sameADMIN_CONSENTtype, no new storage. The provenance sits outside the client-echoed Consent document, so nothing stampable rides the GET→POST round-trip and the content-binding compare is untouched.Tri-state semantics, load-bearing:
consented: true— a record exists AND its snapshot equals the current declaration: live right now, exactly what the request-time gate enforces; the GET and the resolver share onedescribeAdminConsentimplementation and cannot drift.consented: false, stale: true— a grant exists but the declaration changed since; the last approval's provenance and snapshot stay visible for the panel's re-approve view; nothing resolves at runtime.consented: false(rest absent) — withdrawn or never granted.The user-consent path (
acceptConsent,buildConsent,verifyUserConsent,USER_CONSENTstorage) shows zero diff — its who/when are structural.Also folded into this story (the story ships as five commits):
ConsentGrantignores unknown properties (the stored-DTO convention), so a record written by a pre-envelope commit — a bareConsentbody at the same key — deserializes withconsent == nulland flows into the already-built fail-closed path (stale, nothing resolves, withdrawal works) instead of 400-ing every user call and 500-ing the GET.isAdminConsentedcalls one zero-allocationmatches(stored, declaration)predicate rather than delegating through the presentation DTO — no DTO on the per-request hot path, gate semantics can never inherit presentation-driven changes; the provenance-non-participation test now re-stubs with a differentgrantedBy/grantedAtand asserts the yes stands.descriptionis aMANUAL_PREFERRED_FIELD, changing an annotation's response description never propagates to a node that already carries one.Acceptance criteria
{"consented":false}for a non-declaring app; unknown deployment → 404.checkOpenApiDiffclean; committed spec untouched.Blocked by
#1935