| M1-30/1 |
2026-10-12 |
Base int/m1-wave7 at 8e30539, branch feat/M1-30-authz-expiry in its own worktree; the branch is not merged locally and the worktree stays, the PR lane ports it. |
Instructions of the launcher, which override the brief's merge step. |
| M1-30/2 |
2026-10-12 |
No migration: 00005_authz_groups_grants.sql (M1-27) already has expiry_reminded_at and expiry_reported_at on both tables, both (expires_at) indexes and the trigger that sets either grant column once. Only new sqlc queries (queries/expiry.sql). |
Scope 1 of the brief: add only what the merged migration lacks. |
| M1-30/3 |
2026-10-12 |
The jobs are app.ExpiryService (Sweep, Remind) in app/expiry.go; their arguments, handlers and Register live in the new package adapters/jobs, which internal/wire calls from NewJobRegistry. The selections and marks are the separate port app.ExpiryStore, implemented by the PostgreSQL repository and also needed by the rights hook (RightsHookDeps.Expiry); the in-memory fake store does not implement it. |
app may not import internal/platform (only adapters may); sync/adapters/jobs is the same pattern. A separate port keeps the existing Store and its fake unchanged. |
| M1-30/4 |
2026-10-12 |
The reporting of an expiry (selection, marker, grant.changed or group.changed, the audit entry, the views before) runs in the rights begin hook right after permission_version is locked, with the time of the change; the events wait for phase 3.1, where the appender accepts them. A sweep batch passes its kind and limit through the context and its work phase only reads RightsHook.Folded. The deny entry of a violation in a sweep batch names the resource kind (grant or group) without id, because a batch has many rows. |
One clock and one lock for the selection and both views; the appender refuses events in phase 1. |
| M1-30/5 |
2026-10-12 |
Every rights change folds the due, unreported expiries in before anything else (M1-30/4), not only the sweep: they are reported with event, audit entry and marker by the first unit after the expiry, and its access.changed carries the action of that unit. The views treat rows not due yet (and in a sweep batch the rows of a later batch) as not expired (domain.AssumeValid.Unreported). The brief's AddAffectedAssumingValid is not built: the fold records the views before itself. Outside the sweep the fold is not bounded by authz.expiry_sweep_batch_max. |
A view must be what the client was told before the change and what authorize decides after it. With the expiry left to the sweep, three red team rounds reproduced lost losses and a reported gain that authorize denies (a membership and its group grant expiring together, a change between an expiry and the sweep, a new grant to a group of an expired member). The sweep runs every authz.expiry_sweep_interval, so a change folds the rows of at most one interval unless the worker was down. |
| M1-30/6 |
2026-10-12 |
An invariant violation in a batch is logged as ERROR expiry batch rejected by an invariant with bothy.action and error.type (i1_violation or last_owner); the agent ids are in the deny audit entry the rights hook records. |
The log handlers drop every attribute outside the signals catalogue, which has no agent id; the audit entry is the durable record. |
| M1-30/7 |
2026-10-12 |
The reminder excludes grants of deleted groups and memberships of deleted groups in the selection itself. |
A row skipped after the selection would never be marked and would fill every batch again. |
| M1-30/8 |
2026-10-12 |
access.expiring carries target (kind, id, level, capabilities) only for space and channel grants; it is absent for the instance and for hosts. |
Brief Scope 4.3 and 07 section 9.4. The schema AccessTargetKind also knows host for access.expiring; host grants exist from M2-39, whose ticket decides whether a host reminder names its target. |
| M1-30/9 |
2026-10-12 |
group.changed carries the group with member_count = the memberships in effect at the time of the change, counted in phase 3.1 after the use case wrote its own memberships. |
Group.member_count is required; counting at commit keeps it right when a change that adds or removes members also folds an expiry of the same group. |
| M1-30/10 |
2026-10-12 |
internal/wire builds the service with the system clock. |
A sweep batch uses the clock of the rights hook (M1-30/4); the reminder reads its own once per batch. |
| M1-30/11 |
2026-10-12 |
authz.Register now declares grant.changed, group.changed and access.expiring next to access.changed. |
authz appends them from this ticket on; the registration check rejects a second module for them. |
| M1-30/12 |
2026-10-12 |
Configuration: authz.expiry_sweep_interval (duration, default 1m, at least 1s), authz.expiry_reminder_lead (duration, 48h, at least 1s) and authz.expiry_sweep_batch_max (int, 500, 1 to 2147483647, the range of the SQL limit parameter). |
07 section 14 gives the defaults; the bounds keep every value usable. |
| M1-30/13 |
2026-10-12 |
Recipients of access.expiring follow 07 section 9.4 and the brief: for a grant to a group all active owners and granted_by. The note of access.expiring in 05 section 6 (generated from actions.yaml: "to the subject (for groups, the person)") is not changed here. |
The brief rules this recipient set; the catalogue note is outside the files this ticket may change and goes to the specification lane. |
| M1-30/14 |
2026-10-12 |
Both jobs are registered in the one job registry every role builds (as sync.partition_maintenance and sync.idempotency_cleanup); only worker and all run handlers, because only they start River (M1-14). The registration tests check queue, period and arguments, not the role. |
An api process must accept exactly the kinds a worker runs; the role split is the job runtime's and is tested there. |
| M1-30/15 |
2026-10-12 |
Red team round 1 (NEIN, one reproduced major): fixed through AssumeValid.Unreported (M1-30/5) with TestExpirySweep_MembershipAndGroupGrantTogether and TestExpirySweep_TwoGrantsOnOneTargetInTwoBatches. Minors fixed: the host target follows 07 (M1-30/8), the active owners are read once per reminder batch, and adapter tests cover the membership selections and marks. Minors kept: the invariant log without agent ids (M1-30/6); grant.changed of a channel target carries no space_id while internal/wire passes the empty structure source (until M1-53, as for the rights hook of M1-29). |
Findings of the red team fixed in the same ticket where they are in scope. |
| M1-30/16 |
2026-10-12 |
Red team round 2 (NEIN, one reproduced major of the same class: a revocation or removal between an expiry and the sweep reported nothing, and a new grant in that window reported an added the client already had): fixed in one place, the views of the rights hook (M1-30/5), with the regression tests TestExpiryWindow_*. |
The second finding of the same class: fixed for the class, not the instance. |
| M1-30/17 |
2026-10-12 |
Red team round 3 (NEIN, one reproduced major: a new grant to a group whose membership had expired unreported told the expired member added for a target authorize denies): fixed for the class by folding due expiries into every rights change (M1-30/4, /5), with TestExpiryWindow_NoPhantomGain, TestExpiryWindow_GroupDeleteAndReAdd and the changed TestExpiryWindow_NewGrantAfterExpiry. TestExpirySweep_ExactlyOnce makes its failing run with a vetoing subscriber of authorize.AccessChanged, because the sweep's events now go through the appender of the rights hook. |
Third finding in the same area: the mechanism now has one rule (report a due expiry before computing any view) instead of assumptions in the views. |
| M1-30/18 |
2026-10-12 |
Red team round 4 (NEIN, one reproduced major: an expiry folded into a person's rights change was audited with that person as actor, with device, session and IP pseudonym): the audit entries of folded expiries are written from a context without the request principal and without the client IP (systemContext, the values are hidden by their types because the keys of kernel/actor and kernel/reqmeta are unexported); the correlation id of the change stays, so the entry points to the request that folded it. Test TestExpiryWindow_FoldAuditIsSystem. Minor fixed: member_count (M1-30/9). Minor kept: a denied admin request folds before its use case authorizes and then rolls the fold back; correct, only work. |
System actions never pretend a human did them (05 rule 1.10, R-AZ-15); the brief asks the audit row without actor. |
| M1-30/19 |
2026-10-13 |
Port onto main: chapter references name the English files (07-permissions.md, 03-data-model.md), and the background job rows of the authz README are English. |
The specification is English on main; no behaviour changes. |