HANDOFF NOTE: project "permissions" (only this project is authoritative; archive-* records are unrelated and discarded)

1. 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. The function must not mutate its input.

2. Algorithm order (fixed)
1. Subject tenant differs from resource tenant: deny, reason "tenant".
2. now - issued_at > token_ttl: deny, reason "token".
3. If suspended_denies and subject is suspended: deny, reason "suspended".
4. If owner_bypass and subject is owner: allow, reason "owner".
Early returns (steps 1-4) have matched = [].
5. Initial roles are subject.roles, plus groups only if group_union.
6. If inherit_roles, expand roles transitively through role_parents, tolerating cycles.
7. A grant matches when all of these hold:
 - its role is in the role set;
 - its action equals the requested action (ASCII case-insensitive only if action_casefold), or the grant action is "*" and wildcard_action is on;
 - its scope matches resource.id (exact equality when resource_scope="exact", prefix match when "prefix");
 - expires is null, or now<=expires when expiry_inclusive, otherwise now<expires.
8. matched = original indexes of every matching grant.
9. With no matches: allowed = default_allow, reason "default".
10. With matches: reason "grants". If deny_wins, allow only if there is at least one allow and no deny. Otherwise any allow suffices.
11. audit = [reason] only on denied results when audit_denials is on, else [].

3. CURRENT settings (after Updates 1-5)
- deny_wins = false
- expiry_inclusive = false (strict: now < expires)
- inherit_roles = false (no role_parents expansion)
- owner_bypass = false (step 4 is inactive)
- action_casefold = false (case-sensitive action comparison)
- group_union = false (groups are not added to roles)
- resource_scope = "prefix"
- suspended_denies = false (step 3 is inactive)
- token_ttl = 200
- wildcard_action = false ("*" grants do not match unless the action equals "*" literally)
- audit_denials = true
- default_allow = true

Consequences:
- Because default_allow is true, a request with no matching grants is allowed with reason "default". Audit stays [] because it is allowed.
- Suspended and owner subjects get no special handling. They proceed to grant evaluation, and only the tenant and token checks apply before it.
- With deny_wins false, any allow grant among the matches allows the request.
- Denied results carry audit = [reason], for example ["tenant"], ["token"], or ["grants"].

4. Unresolved or unspecified items
- The grant schema is not stated: how a grant records allow versus deny (e.g. an effect field), and its field names for role, action, scope, expires. Confirm before implementing.
- The prefix scope rule is assumed to mean resource.id starts with grant.scope. The exact wording ("scope is prefix") is ambiguous about direction. Confirm.
- The token check uses strict greater-than (now-issued_at > 200). The boundary at exactly 200 is therefore not denied.
- The type of "now" and the unit of token_ttl are unstated. Assume numeric and the same units.
- role_parents shape (role -> list of parents) is assumed. It is unused while inherit_roles is false.
- With wildcard_action false, the "*" literal-match behaviour is an assumption. It could instead mean "*" never matches. Confirm.

5. Next steps
Await further updates. Each update replaces only the keys it names. Resolve conflicts by taking the latest value per key. Ignore records about other projects.