approval-engine/docs/access-claim-fact-reader.md
tegwick 3485415b82 Add producer-owned Go approval fact validation and canonical vectors
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
2026-10-16 16:25:54 +02:00

137 lines
8.9 KiB
Markdown

# Thin Access Approval fact reader — owner source contract proposal
Owner scope: Approval Engine / PIP. Work: APPROVAL-WP-0005-T01, selected source
subset of APPROVAL-WP-0002-T01/T05 under CUST-WP-0080. This interprets the existing
claim schema 0.1, not a new API/schema/signature or policy grant. Access's B2
implementation stays DENY until its separately reviewed complete successor.
## Authoritative source and guarantee
Only server-installed, independently admitted provider origin and transport may
GET `/v1/approvals/{id}/claim`, with least-scope `approval:read` credential and
exact admitted store tenant. No caller URL/opener/key/verified flag, redirects,
environment proxy, caller JSON or public same-channel key bootstrap can supply
this provenance. Owner transport must enforce total deadline/size, exact 200
application/json UTF-8, no encoding, duplicate-key/nonfinite/malformed JSON
refusal, no retry and bounded errors without bearer/body leakage. Provider
unavailability refuses this source join. Never invoke a credential-carrying
Python subprocess as the Go adapter; reuse Go's existing transport boundary.
At the provider observation, `valid_now: true` AND `binding.human_control: true`
means the configured required count of distinct entries is met and every entry
has verified `principal_type=human`. `/entries` obtains identities/types only from
its authenticator, rejects non-human binds for this declaration, and persists
UNIQUE(approval_id, subject_id); `_evaluate` repeats the threshold/human check.
It does NOT mean required_count is two, requester/actor/principal is human,
directory-derived tenant membership, a particular MFA/assurance level, policy
eligibility, uncompromised source, native issuer admission, or continuing
validity after the observation. Consumer trusts this owner evaluation instead
of fetching identities/counting approvers or revalidating human JWTs. Native
issuer/profile acceptance is still T01, not established by synthetic vectors.
## Minimal typed fact validation
The submitted claim and fetched observation must each satisfy existing schema
0.1 with exact JSON types; `human_control` must be present and exactly true on
this lane despite being historically optional in the generic schema. Require
schema_version=0.1, kind=approval-claim, issuer=approval-engine, exact expected
approval UUID, state=valid, valid_now=true, consumed=false, reason_code=ok,
pdp_path=true and non-null lowercase sha256 pdp_digest. Reject decision-shaped
keys and unknown schema members (except optional informational yields_to).
The signed/requesting service must never become a human approver by inference.
At one captured UTC validation instant now, require zoned RFC3339 timestamps,
positive integral ttl_seconds, observed_at<=now<not_after and
not_after=observed_at+ttl_seconds. Require not_before<=now<expires_at. Do not
invent skew or extend TTL; a separately admitted clock policy is needed to
relax the zero-skew default. The provider default TTL is 30 seconds but source
configuration may differ; an Access-owned maximum may only shorten it.
Compare exact five-field act against the owner-derived normalized proposed act,
including all nested target values; no action vocabulary translation is owned
here. Recompute native binding.digest from those five fields and require exact
served and expected digest correspondence. Match pdp_digest to Access's actual
ApprovalBindingDigest over the exact normalized original request (excludes only
top-level context.approval). Preserve execution_contract and all other context.
Never substitute full request_digest, native act digest or a guessed Go digest.
The PDP digest proves correspondence, not decision authenticity or approval
source authenticity. Registry/normalization changes must fail or reissue.
## Original request is immutable; fresh observation stays out-of-band
The original submitted context.approval must remain byte/value-equivalent under
Access's existing canonical request rules. Do not replace it, update freshness,
change binding/declarations, or manufacture an evaluated refreshed request.
Validate its own original freshness/validity too; a newly fetched observation
cannot rescue a stale original claim. Require all non-freshness authority facts
to match between the submitted and fetched claims: schema/kind/issuer/id,
state/valid_now/consumed/reason_code, binding and validity. Optional yields_to
is informational only. Each native binding is independently recomputed.
Freshness is validated separately on both observations; a later observation
may have a later freshness window without changing the signed request.
Return a package-private/server-created typed observation handle only after
these checks. Access may bind its source identity/revision, observed_at,
not_after, fetched-claim content digest and validation time into owner-produced
signed provenance separately from unchanged request_digest and
approval_binding_digest. This does not require changing the Approval schema;
Access owns the minimal existing provenance representation and signer contract.
An HTTP caller cannot instantiate this handle or set its verified state.
Suggested Go boundary, for Access to implement in its existing process:
`ApprovalFactSource.Observe(ctx, approvalID) (Observation, error)` reads only
provider bytes plus source provenance; a separate package-private validator
joins Observation with the immutable normalized request, submitted claim and
captured validation time. The handle retains detached raw provider bytes and
strict parsed numbers (json.Number), not caller-installable validation flags.
Neither this reader nor its validator evaluates policy. Access's existing
single policy evaluator consumes the verified factual handle without rewriting
the request. Callers may not supply a ready-made Observation on the HTTP body.
Effective input deadline is the minimum of ORIGINAL submitted freshness,
fetched freshness, approval expiry, authenticated caller expiry and operation/
decision deadlines. No equality at an expiry boundary is accepted. Refetching
does not renew the original full request. Revocation/use may race a read; the
PEP still performs one original exact consume CAS before effect and retains
the same live Platform journal admission, deadlines and unknown-outcome rules.
Access never consumes or executes. Historical confirm_consumption cannot
substitute for this fresh predecision source validation.
## Go canonicalization and golden vectors
`examples/access-claim-reader-vectors.json` contains producer-generated native
canonical UTF-8 bytes/digests and source lifecycle observations, plus explicitly
mutated refusal fixtures. Python dict equality and Go decoding numbers into
float64 lose authority distinctions: True/1, 1/1.0 and integers above 2^53 differ.
Go default json.Marshal additionally escapes HTML/U+2028 differently. Retain
number tokens with UseNumber and implement/test compatibility with Approval's
existing Python json.dumps(sort_keys=True,separators=(',', ':'),ensure_ascii=False)
format; do not silently normalize types or hash whole binding/freshness.
UseNumber preserves number tokens but is NOT a canonicalizer: producer parsing
of `1e-7` emits `1e-07`, `1e20` emits `1e+20`, `1.0` remains distinct from `1`,
and negative floating zero emits `-0.0`. A signed typed Go envelope alone does
not preserve or authenticate Python's original numeric representation.
These finite vectors are a minimum, not proof of a complete Python float/string
port. A thin pilot may fail closed on unsupported numeric/string domains only
if its actual owner-compiled operation contract excludes those values and Access
declares/tests that boundary. It may not silently change the global Approval
algorithm or accept a digest without checking the proposed act. Required vectors
cover bool/int/float distinction, large integer, Unicode/HTML/U+2028, reordered
keys, nested containers, exponent formatting and negative floating zero. These
are authoritative producer vectors for the domains shown, not arbitrary new
restrictions on current pilot metadata. No currently authoritative narrow numeric
restriction was established by this review, so it cannot be presumed. If a
small shared producer-owned Go canonicalization helper becomes necessary, scope
it explicitly to the actual consumer need and test against these producer bytes;
do not invent a second unverified serialization algorithm in the adapter. This
initial review provided vectors only. Subsequent scoped WP0005-T03 supplies
`adapters/go/approvalfact`, a producer-owned pure data implementation agreed with
Access; it adds no credential transport/runtime. The Go consumer owner must
return exact accepted/refused vector results and transport negatives
(redirect/proxy/duplicate/size/deadline).
The superseded service-http-join-proposal-r1 B3 refreshed-evaluation wording is
changes-requested for this immutable-context path. B2's source reader can proceed
against this proposal after Access's own design/conformance review. No foreign
owner assent, policy activation, live credential or native acceptance is implied.