feat: admin-consent status GET + ConsentGrant provenance - #1945
Open
serguei-gorokhov wants to merge 5 commits into
Open
serguei-gorokhov wants to merge 5 commits into
serguei-gorokhov wants to merge 5 commits into
Conversation
GET /v1/consent/{deployment_id}/admin-consent completes the verb set —
the Admin Panel can now 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 (on the envelope's consent section) is untouched.
Tri-state semantics, load-bearing: consented means live right now — a
record exists AND its snapshot equals the current declaration — exactly
what the request-time gate enforces; the GET and the resolver share one
describeAdminConsent implementation and cannot drift. A stale record
reports consented:false, stale:true with the last approval's provenance
and snapshot still visible for the panel's re-approve view; withdrawn
or never-granted reports a clean consented:false. The user-consent
path (acceptConsent, buildConsent, verifyUserConsent, USER_CONSENT
storage) shows zero diff — its who/when are structural (the record's
bucket is the consenting user; the blob metadata is the when).
Spec: documentation repo, offline-access-delegation/implementation-specs/pr3b-admin-consent-status.md
Co-Authored-By: Claude Code <noreply@anthropic.com>
Security-review follow-up to the status-GET commit: ConsentGrant deserialized the stored admin-consent record with the strict mapper (FAIL_ON_UNKNOWN_PROPERTIES is the codebase default), so a record written by the pre-envelope commits of this branch — a bare Consent body at the same key — threw on every read: the resolver 400'd every user call to a declaring app, the new status GET 500'd, and withdraw threw before the delete, making a legacy record un-withdrawable except by re-grant. ConsentGrant now ignores unknown properties (the stored-DTO convention — Conversation, Publication, …): a legacy body deserializes with consent==null and flows into the already-built fail-closed path — stale, nothing resolves, withdrawal works. Pinned by a unit test feeding the legacy body shape through describeAdminConsent. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ticipation Code-review follow-up to the status-GET commit: - isAdminConsented no longer delegates through the presentation DTO — the request-time gate and the status read now call one zero-allocation matches(stored, declaration) predicate: nothing drifts, no DTO on the per-request hot path, and gate semantics can never silently inherit presentation-driven changes to describeAdminConsent. - The 'provenance never participates in the binding' test assertion was vacuous (the stub never varied); it now re-stubs with a different grantedBy/grantedAt over the same snapshot and asserts the yes stands. - The GET's 200-response description in the OpenAPI annotations now states the tri-state contract (consented = live right now, exactly what resolution enforces; stale keeps the last approval's provenance and snapshot) — the annotation channel is the repo's endpoint-doc mechanism (the custom ApiOperation has no operation-level description attribute). Co-Authored-By: Claude Code <noreply@anthropic.com>
…I response description The SpecMerger deliberately preserves committed-spec descriptions over skeleton regeneration (description is a MANUAL_PREFERRED_FIELD), so the response-description slot is the designed home for endpoint contract prose — the custom ApiOperation annotation has no operation-level description attribute. Verified the description survives a full replaceSpec run. Co-Authored-By: Claude Code <noreply@anthropic.com>
Reverts the hand-edited response description from the previous commit. The SpecMerger's MANUAL_PREFERRED_FIELDS precedence means a committed description is a stable fixed point across regenerations, so the edit was mechanically safe (checkOpenApiDiff stays green) — but the team's practice since the automation pipeline is that the spec is not touched manually; the precedence rule is migration scaffolding freezing first-generation strings, not an active hand-curation channel. The tri-state contract lives in the implementation spec, the JavaDoc, and the annotation description (which also documents intent in code). Pipeline gotcha worth knowing, flagged for the team: because description is manual-preferred, changing an annotation's response description never propagates to a node that already carries one — annotation descriptions only flow for newly added nodes. Co-Authored-By: Claude Code <noreply@anthropic.com>
serguei-gorokhov
requested review from
Oleksii-Klimov and
astsiapanay
as code owners
September 9, 2026 18:56
This was referenced Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Applicable issues
Description of changes
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 (on the envelope's consent section) is untouched.Tri-state semantics, load-bearing: consented means live right now — a record exists AND its snapshot equals the current declaration — exactly what the request-time gate enforces; the GET and the resolver share one
describeAdminConsentimplementation and cannot drift. A stale record reportsconsented:false, stale:truewith the last approval's provenance and snapshot still visible for the panel's re-approve view; withdrawn or never-granted reports a cleanconsented:false. The user-consent path (acceptConsent,buildConsent,verifyUserConsent,USER_CONSENTstorage) shows zero diff — its who/when are structural.The story ships as five commits; three follow-ups are part of this PR's intended diff:
2b23fb40— legacy-record leniency (security review):ConsentGrantignores unknown properties (the stored-DTO convention — Conversation, Publication, …), so a record written by the pre-envelope commits of this stack — 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, 500-ing the GET, and making a legacy record un-withdrawable. Pinned by a unit test feeding the legacy body shape throughdescribeAdminConsent.1b4cb8ea— single-sourced gate predicate (code review):isAdminConsentedno longer delegates through the presentation DTO — the request-time gate and the status read call one zero-allocationmatches(stored, declaration)predicate: nothing drifts, no DTO on the per-request hot path, and gate semantics can never silently inherit presentation-driven changes. The provenance-non-participation test was vacuous (the stub never varied); it now re-stubs with a differentgrantedBy/grantedAtover the same snapshot and asserts the yes stands.402836d5+81a2b610— 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 (the team's practice since the automation pipeline). Pipeline gotcha flagged for the team: becausedescriptionis aMANUAL_PREFERRED_FIELD, changing an annotation's response description never propagates to a node that already carries one — annotation descriptions only flow for newly added nodes.Behavior changes flagged for review
GET /v1/consent/{deployment_id}/admin-consent; tri-state response; non-declaring app returns 200{"consented":false}(not 400 like the writers — deliberate: reads inform).Checklist
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
🤖 Generated with Claude Code