Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ tests/ mirrors krater/ layout
of its own app (scope `directory`). Its token for Quilt (scope `quilt`) comes from `WeaveClient.quilt_token`.
- **Events for Quilt go through the outbox.** A service that changes a project's status, title, ledger or spend
calls `krater.services.quilt_events.sync_project` in the same transaction. Never post to Quilt from a request.
- **Every budget grant passes the guardian gate** (`krater.services.guardian.require_team_cleared`): no grant while a
team member is `minor` in Weave without `guardian_verified`, or the directory doesn't return them. Weave owns that
check; Krater stores no copy, and no admin override bypasses it (`docs/SPEC.md` "Guardian verification").
- **Every admin override writes an `AuditEvent`** (who, what, reason). Budget changes write a `BudgetEntry` (append-only;
never update or delete ledger rows).
- **Stub mode** (`KRATER_WEAVE_MODE=stub`) replaces Weave with a local fixture of fake users for dev and tests. The
Expand Down
55 changes: 53 additions & 2 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,9 @@ Postgres. All money is stored as **integer cents** (`*_cents`). Names are sugges
- rejects self-review (submitter or credited builder);
- records the Review against the current revision;
- asks `ApprovalPolicyService` whether the policy is now satisfied.
4. **Approve.** When the policy is satisfied, the revision is approved, a `BudgetEntry(initial_approval)` is written, and the
project moves to `approved`.
4. **Approve.** When the policy is satisfied, Krater runs the guardian gate (see "Guardian verification"). If the gate
passes, the revision is approved, a `BudgetEntry(initial_approval)` is written, and the project moves to
`approved`. If it fails, nothing is recorded and the reviewer sees which members need a verified guardian.
5. **Reject.** A reason is required. The project moves to `changes_requested`, and the submitter edits and resubmits
(step 2, same project, new revision).
6. **Amend.** An `approved` project can have its scope or budget amended. This creates an `amendment` revision reviewed
Expand Down Expand Up @@ -237,6 +238,53 @@ Uploads use presigned URLs, and only objects from approved completion revisions
spend, through Quilt's patch API. The events go through an outbox table in the same transaction as the change.
See [quilt-integration.md](quilt-integration.md).

## Guardian verification

Added Oct 12, 2026, as a safeguard for Project Ganymede. Weave asks a parent or guardian to consent by email for a
member under 18. An email link proves control of an inbox, not parenthood. So a Weave admin also records a
stronger check in Weave: a signed guardian form plus a short call or video check with the guardian. The check
lives in Weave, so every Patchwork Labs app can use it. Krater stores none of it.

**The rule.** Krater grants no budget to a project while a team member is a minor without a verified guardian,
or while Weave's directory doesn't return a team member.

- **The team** is the submitter plus the credited builders on the revision that is decided.
- **The data** comes from Weave's directory record: `minor` (true when the member is under 18) and
`guardian_verified` (true when a Weave admin recorded the form and the call). A member blocks the grant when
`minor` is true and `guardian_verified` is not true.
- **No directory record.** Weave's directory answers 404 for anyone the Krater app may not serve. That includes a
minor whose guardian consent is missing or was revoked. So a 404 blocks the grant, and the member is listed as
"not available in Weave (no access to Krater)".
- **When.** Krater asks Weave's directory fresh at decision time, the same way it checks roles. It checks on
every grant: a proposal approval, an amendment approval that raises the ceiling, and an admin budget increase.
A rejection, a completion approval, a cut and a reclaim grant nothing, so they have no check.
- **One rule for every path.** The gate is in `krater.services.projects` (through `krater.services.guardian`). The
web Approve button, the Slack Approve button and the admin overrides all reach it.
- **No override.** This is a safety rule. An admin override can't bypass it, and Krater has no setting to turn it
off. A Weave admin verifies the guardian first, then a reviewer or admin approves again.
- **Unknown age.** Older Weave versions send a record without `minor`. Weave ships the fields before Krater needs
them, so Krater treats that record as "not known to be a minor". It logs a warning with the member's Patchwork
ID, so an operator can see when Weave is behind.
- **Weave down.** If Weave can't be reached, the grant fails with "try again". Krater never guesses.
- **The project page** shows a notice to everyone who can see the project. The notice lists the members who need
to be cleared in Weave and the reason for each, and says that a Weave admin records a verified guardian on the
member's Weave admin page.

```mermaid
flowchart TD
A[Approval, amendment increase or admin budget increase] --> B[Ask Weave's directory, fresh, for each team member]
B -->|Weave can't be reached| X[Refuse: try again]
B -->|404, no record| N[Refuse the grant and list the member: not available in Weave]
B --> C{minor: true?}
C -->|missing| L[Log a warning, not known to be a minor] --> OK
C -->|false| OK[Member is clear]
C -->|true| D{guardian_verified: true?}
D -->|yes| OK
D -->|no or missing| R[Refuse the grant and list the member]
OK --> E{Every member clear?}
E -->|yes| G[Grant: write the BudgetEntry]
```

## Admin overrides

At any point an admin can:
Expand All @@ -250,6 +298,9 @@ can't decide it, add to its budget or raise its hourly cap, so an admin can neve
else signing off. Another
admin, or the normal review, has to do it. Cutting or reclaiming its budget and withdrawing it are still allowed.

An override also can't bypass the guardian gate (see "Guardian verification"). An admin approval or budget increase
fails the same way a reviewer approval does while a team member needs a verified guardian.

Every override writes an `AuditEvent` with who, what, when and a required reason. Budget changes also write a
`BudgetEntry`. Overrides are posted in the project's channel so reviewers can see them.

Expand Down
65 changes: 58 additions & 7 deletions docs/weave-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Krater depends on this Weave work. All of it is merged on Weave `main`:
- app-defined roles: [patchworklabsorg/weave#165](https://github.com/patchworklabsorg/weave/pull/165);
- the directory API: [patchworklabsorg/weave#166](https://github.com/patchworklabsorg/weave/pull/166).

The guardian gate also reads the directory fields `minor` and `guardian_verified`. Weave adds them now. Krater works
with a Weave that doesn't send them yet (see "Guardian verification" below).

A Weave superadmin must create the roles `member`, `reviewer` and `admin` on the Krater app page. An admin must add `directory` to the Krater app's scopes.

| Capability | Used for |
Expand All @@ -27,7 +30,7 @@ A Weave superadmin must create the roles `member`, `reviewer` and `admin` on the
| Claim `groups` (scope `groups`) | Group slugs linked to the Krater app; the fallback when `roles` is absent |
| Claims `slack_id`, `slack_member` (scope `slack`) | Mapping Slack clicks to users, and the Slack membership gate |
| The client_credentials grant, scope `directory` | Krater's own token for the directory API |
| `GET /api/v1/directory/users/{sub}` | The fresh role check before every action, and names and emails when a page needs them |
| `GET /api/v1/directory/users/{sub}` | The fresh role check before every action, the guardian gate before every budget grant, and names and emails when a page needs them |
| `GET /api/v1/directory/users?role=<key>` | "Everyone with role X", e.g. reviewers to invite to Slack channels, or finding a member by email |

Krater asks for the sign-in scopes `openid profile email groups roles slack`.
Expand Down Expand Up @@ -98,8 +101,10 @@ Krater calls the directory with an access token of its own OAuth app:
Endpoints:

- `GET /api/v1/directory/users/{sub}` answers
`{sub, name, email, email_verified, slack_id, slack_member, groups, roles, active}`. A 404 means the user is unknown
or may not use Krater: Krater treats them as not a member. Only `active: true` counts as active.
`{sub, name, email, email_verified, slack_id, slack_member, groups, roles, active, minor, guardian_verified}`. A 404
means the user is unknown or may not use Krater: Krater treats them as not a member. Only `active: true` counts as
active. `minor` and `guardian_verified` are booleans that older Weave versions leave out. Krater reads a value
that isn't a boolean as missing.
- `GET /api/v1/directory/users?role=<key>` (or `?group=<slug>`, which Krater doesn't use) answers
`{"users": [...]}`, sorted by `sub`. Send exactly one parameter. A role or group that isn't linked to Krater answers
404: Krater treats it as an empty list and logs a warning.
Expand All @@ -122,7 +127,7 @@ class WeaveClient(Protocol):

`WeaveIdentity` (from the id_token) and `WeaveUser` (from the directory) carry `sub`, `name`, `email`,
`email_verified`, `slack_id`, `slack_member` (`None` when Weave didn't say) and `roles` (Krater names). `WeaveUser` also
carries `active`. `exchange_code` verifies the id_token against Weave's JWKS (signature, `iss`, `aud`, `exp`, `nonce`).
carries `active`, `minor` and `guardian_verified` (each `None` when Weave didn't say). `exchange_code` verifies the id_token against Weave's JWKS (signature, `iss`, `aud`, `exp`, `nonce`).

### Sign-in (`krater.services.users.sign_in`)

Expand All @@ -148,6 +153,49 @@ navigation only.
The gate prefers Weave's `slack_member` from the fresh directory record. When Weave doesn't say, Krater asks Slack
(see [SPEC.md](SPEC.md#roles--authentication)).

### Guardian verification

Weave owns the guardian check for members under 18. A Weave admin records a signed guardian form plus a call or
video check on the member's Weave admin page. Weave's directory then sends `guardian_verified: true`. Krater stores
no copy.

Before every budget grant, `krater.services.guardian.require_team_cleared` asks the directory by `sub`, with
`fresh=True`, for each team member (the submitter and the credited builders):

| Directory record | Krater's answer |
| --- | --- |
| `minor: true`, `guardian_verified: true` | Clear |
| `minor: true`, `guardian_verified` false or missing | Blocks the grant, and the member is named in the error |
| `minor: false` | Clear |
| No record (404) | Blocks the grant, and the member is named as "not available in Weave (no access to Krater)" |
| A record without `minor` | Not known to be a minor: clear, with a warning in the log |
| Weave can't be reached | The grant fails ("try again") |

A 404 blocks because Weave's directory hides anyone the Krater app may not serve, a minor whose guardian consent
is missing or was revoked included. Krater treats a record without `minor` as "not known to be a minor" because
Weave ships the field first. When every
deployment's Weave sends it, the warning in the log should stop. If it doesn't, check the Weave version.

```mermaid
sequenceDiagram
participant R as Reviewer (web or Slack)
participant K as Krater (record_review)
participant W as Weave directory
R->>K: Approve
K->>W: GET /api/v1/directory/users/{sub} (fresh), for each team member
W-->>K: {minor, guardian_verified, ...}
alt a minor without guardian_verified: true, or a 404
K-->>R: Refused: these members must be cleared in Weave
else Weave can't be reached
K-->>R: Refused: try again
else every member clear
K-->>R: Approved, budget granted
end
```

The project page uses the same lookup (from the short cache) to show which members block a grant, and why. If
Weave can't answer, the page says so and still loads.

### Disabling someone

Do it in Weave: remove their Krater roles or their access to the Krater app, and revoke their tokens. The next action
Expand All @@ -158,8 +206,11 @@ can keep showing what they could see for up to 60 seconds.

`KRATER_WEAVE_MODE=stub` (refused in production) replaces Weave with `krater/weave/stub_users.json`. Each fixture
user has the fields a real Weave sends: `sub`, `name`, `email`, `email_verified`, `slack_id`, optional
`slack_member`, `groups` (slugs) and `roles` (keys). One user (`PWLREVIEWERTWO`) has no `roles` field, to exercise the
group fallback. The stub client answers the directory lookups from the same fixture with the same `RoleMapping`.
`slack_member`, `groups` (slugs), `roles` (keys) and optional `minor` and `guardian_verified`. One user
(`PWLREVIEWERTWO`) has no `roles` field, to exercise the group fallback. `PWLMINORONE` is a minor without a verified
guardian, `PWLMINORVERIFIED` is a minor with one, and `PWLMEMBERTWO` has no `minor` field. The stub client answers the
directory lookups from the same fixture with the same `RoleMapping`.

Tests change what Weave says mid-test with `StubWeaveClient.set_roles`, `set_active`, `remove_user` and `put_user`. The
Tests change what Weave says mid-test with `StubWeaveClient.set_roles`, `set_active`, `set_minor`,
`set_guardian_verified`, `remove_user` and `put_user`. The
`weave_stub` fixture in `tests/conftest.py` is the stub the test app uses.
28 changes: 28 additions & 0 deletions krater/services/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,31 @@ def __init__(self, errors: dict[str, str]) -> None:

class NotFound(DomainError):
"""The requested record doesn't exist (or isn't visible to this actor)."""


class GuardianVerificationRequired(InvalidState):
"""Krater can't grant budget: a team member is under 18 without a verified guardian in Weave, or Weave's
directory doesn't return them.

`members` holds `(label, reason)` for each such member, so reviewers and admins see who blocks the grant and
why (docs/SPEC.md "Guardian verification")."""

def __init__(self, members: list[tuple[str, str]]) -> None:
self.members = members
listed = "; ".join(f"{label}: {reason}" for label, reason in members)
super().__init__(
"Krater can't grant budget to this project yet. These team members must be cleared in Weave first: "
+ listed
+ ". A Weave admin records a verified guardian (the signed form and the call) on the member's Weave "
"admin page."
)


class GuardianCheckUnavailable(InvalidState):
"""Krater couldn't ask Weave about the team members' ages, so it grants no budget (fail closed)."""

def __init__(self) -> None:
super().__init__(
"Krater couldn't reach Weave to check the team members' ages, so it granted no budget. "
"Try again in a minute."
)
Loading
Loading