HANDOFF NOTE: project "permissions" (current as of Update 5)

SCOPE
Only the permissions project is authoritative. All "archived work records" (archive-west/east/north/south-NN) belong to other projects and were discarded. Their settings do not apply here.

INTERFACE
evaluate(payload) -> {allowed: boolean, reason: string, matched: [grant_index], audit: [reason]}
- Subject: tenant, roles[], groups[], owner, suspended.
- Resource: tenant, id.
- Payload: now, issued_at, action, grants, role_parents.
- Inputs are ordinary ASCII. Do not mutate inputs.

CURRENT CONFIGURATION (after Updates 1-5 merged)
- deny_wins = true
- expiry_inclusive = true
- inherit_roles = true
- owner_bypass = true
- action_casefold = true (Update 2 set false; Update 5 set true)
- group_union = true (Update 2 set false; Update 5 set true)
- resource_scope = "exact" (Update 2 set prefix; Update 4 set exact)
- suspended_denies = true (Update 2 set false; Update 4 set true)
- token_ttl = 100
- wildcard_action = true
- audit_denials = false (Update 5)
- default_allow = false (Update 5)

ALGORITHM (fixed order)
1. Subject tenant differs from resource tenant: deny, reason "tenant", matched=[].
2. now - issued_at > token_ttl (100): deny, reason "token", matched=[].
3. suspended_denies and subject suspended: deny, reason "suspended", matched=[].
4. owner_bypass and subject is owner: allow, reason "owner", matched=[].
5. Role set: start with subject.roles plus subject.groups (group_union is on). Because inherit_roles is on, expand transitively through role_parents, tolerating cycles (use a visited set).
6. A grant matches if all hold:
   - Its role is in the role set.
   - Its action equals the requested action, ASCII case-insensitive (action_casefold on), or the grant action is "*" (wildcard_action on).
   - Its scope equals resource.id exactly (resource_scope exact).
   - Its expires is null, or now <= expires (expiry_inclusive on).
7. Collect the original indexes of all matching grants into matched.
8. With no matches: allowed = default_allow (false), reason "default".
9. With matches: reason "grants". With deny_wins on, allowed only if at least one allow grant matches and no deny grant matches.
10. audit is [reason] only for denied results when audit_denials is on. audit_denials is currently false, so audit is always [].

Early-return results (steps 1-4) have matched=[].

UNRESOLVED / NOTES
- The input format that marks a grant as allow vs deny (e.g. an effect field) is not specified. Confirm with the owner or the fixtures.
- Prefix scope behavior is defined as a simple string prefix, but it is inactive now. Implement it only if resource_scope can change.
- Boundary of the token check: exactly now-issued_at == 100 is allowed (the check is strictly greater than).
- Behavior for missing or null fields is unspecified (assume well-formed ASCII input).
- No further updates are known. Apply any later "Update N" as a key-wise replacement of these values, leaving other keys unchanged.