Token system and grouped time limits (#8, #5) #107

Merged
albert merged 12 commits from u/albert/token-system into main 2026-07-26 01:25:55 +00:00
Owner

Adds two related ways to shape when an activity is available, plus the
management-app support to see and override them. Along the way it fixes a
pre-existing break in the Android companion and replaces its hand-written wire
mirrors with generated ones, so that class of break can't recur.

Closes #8. Closes #5.

Issue #8 — token system

An activity can carry an [entries.tokens] block naming the activities whose
time unlocks it. Time works as a currency: sessions on the source activities
bank a balance on the target, and the target's own sessions spend it back down.

[entries.tokens]
from = ["tuxmath", "gcompris", "scummvm-putt-putt"]
earn_ratio = 0.5             # two minutes earned banks one
minimum_seconds = 600        # don't unlock for less than ten minutes
max_balance_seconds = 3600   # don't bank more than an hour (0 = unlimited)
carry_over = false           # unspent time expires at local midnight

The session is capped at the banked balance, so it can never be overspent, and
the activity re-locks once the balance is gone. The issue's other two bullets —
manual and external-API conditions — are already covered by daily overrides and
the management API, so only the time-based case is implemented.

Issue #5 — grouped time limits

A [[groups]] block defines a category that shares one schedule and one
budget; entries join with group = "<id>".

[[groups]]
id = "attention-heavy"
label = "Games"

[groups.limits]
max_run_seconds = 900        # short bursts, per session, for any member
daily_quota_seconds = 3600   # COMBINED across all members
cooldown_seconds = 600       # any member's session cools down the whole group

The daily quota is the combined usage of the members, so one game can spend
the category's whole budget and take its siblings down with it — the behaviour
the issue asks for. A group cooldown is started by any member and applies to
all of them, which is the main reason to set one: a per-entry cooldown is
trivially dodged by starting a different game in the same category.

Groups interoperate with tokens in both directions: from = ["group:<id>"]
banks time from every member of a category, and [groups.tokens] gates a whole
category so earning unlocks all of it at once.

How the limits compose

Every limit now exists at two levels and composes strictest-wins: a member
must satisfy both its own limits and its group's, and the session is capped by
the tighter of each. A group-level failure is reported as GroupRestricted
wrapping the underlying reason, so a UI can say "Games: daily limit reached"
and every future entry-level reason works at group level for free.

Hides the entry Caps the session availability = true override bypasses
Availability window yes yes yes
Daily quota yes yes yes
Cooldown yes no
Token gate yes yes yes
max_run yes no (only the daily quota is lifted)

Storage

Cooldowns, token balances, and daily overrides are keyed by a LimitSubject
rather than an entry ID, so a group holds the same state an entry does —
including its own daily override.

The key format is what makes this cheap: an entry subject renders as the bare
entry ID
, and only groups take a group: prefix. Every pre-existing row and
API call is therefore already valid, and the migration is metadata-only — an
ALTER TABLE … RENAME COLUMN guarded by a PRAGMA table_info check, with no
row read or rewritten. test_legacy_entry_keyed_tables_are_migrated_in_place
builds a real pre-groups database on disk and asserts the old cooldown and
override survive.

Entry and group IDs are both forbidden from starting with group:, which keeps
the encoding unambiguous.

⚠️ Breaking change

DailyOverride.entry_id is now DailyOverride.subject on the wire.

The web UI, the Android companion, the dispatch tests, and the generated RPC
schema are updated here. Any other consumer of that field needs the same
change. Input is backward compatible — upsert_override still accepts a bare
entry ID — but the response field is renamed.

Management apps

Groups were initially invisible to both apps, and auditing that turned up a
pre-existing break in the companion.

Android repair. The companion's ReasonCode mirror had drifted four
variants behind the device: not_ready (#76) and required_input_unavailable
(#96) were already missing before this branch added two more. kotlinx throws on
an unknown polymorphic discriminator — ignoreUnknownKeys only covers unknown
fields — and reasons is nested inside EntryView, so a single unrecognised
reason failed the decode of the whole list_entries response. DailyOverride
also still read entry_id, and loadOverride swallowed the resulting
exception with runCatching{}.getOrNull(), making a failed load
indistinguishable from "no override set" — the editor rendered a blank form
over a real override, and saving would have silently overwritten it.

Both apps now show a Categories section with combined-usage progress and
override controls addressing group:<id>, and activity rows name their
category. The web app's override mutations take a LimitTarget; Android's
OverrideSection is keyed by subject — in both cases category and activity
controls are the same code path.

Generated wire types

RpcMethods.kt was method-name constants only, and ManagementClient used raw
string literals rather than those constants, so the drift check was guarding
nothing that mattered. Neither Android break was catchable from the method
schema, which records only type names from the trait signature.

The wire types now carry JsonSchema derives, and a new shepherd-wire-codegen
crate renders that schema into WireTypes.generated.kt — 38 declarations, doc
comments carried across from Rust. The drift test covers that file, so a Rust
field change fails CI until the Kotlin is regenerated.

Every generated sealed interface gets an Unknown variant and a matching
polymorphic default, which generalises the ReasonCode fix: any tagged enum
can gain a variant, and an older companion degrades that one value instead of
failing everything around it.

The crate is a leaf, deliberately outside default-members. That placement
does two jobs: it sits above shepherd-ble, so the BLE claim types generate
too (a generator inside shepherd-management would be a dependency cycle); and
it keeps schemars behind a schema feature that nothing shipped enables —
cargo tree -p shepherdd shows no schemars at all.

Generation immediately improved three types that had been hand-typed as
strings: RequiredInputUnavailable.devicesList<InputDeviceType>,
AdminRecord.roleAdminRole, and DeviceInfo.protocolVersionLong.

Three types stay hand-written, and the generator panics rather than
emitting something subtly wrong if it meets their shapes: LaunchOutcome is
externally tagged, and Event/EventPayload have a variant that flattens a
$ref beside its tag, which kotlinx cannot express.

Testing

  • 51 Rust test binaries, clippy --all-targets -- -D warnings, cargo fmt
  • 23 Android JVM tests, including wire tests for every previously-missing
    reason variant, subject-keyed overrides, and an unknown-code payload
    asserting it degrades without taking its siblings down
  • tsc --noEmit and a production web build
  • config.example.toml passes shepherd config validate
  • Codegen is idempotent; the drift guard was verified by probing it with a
    doc-comment change and watching CI-equivalent failure

End-to-end via the headless dev session:

  • Tokens: a gated activity was absent from the launcher, appeared after a
    127-second source session, and reported max_run_if_started_now = 7620s
    127 × the ratio of 60, exactly the banked balance — on an entry with no
    configured max_run.
  • Groups: both members of a category reported max_run clamped to the shared
    120-second quota while an ungrouped activity stayed uncapped; one member's
    session then removed both members with two stacked group_restricted
    reasons (shared cooldown + exhausted combined quota); a group:games
    override cleared the quota reason, leaving only the cooldown, which nothing
    bypasses by design.
  • Web UI: verified visually against the real stack — the category card rendered
    "2m of 30m used today" with its members reading "Games: Cooling down".

Known gaps

  • The Android UI is not visually verified. It needs an emulator and a
    paired BLE device; CI runs JVM unit tests only and the pair/claim path is a
    documented manual smoke test. Coverage there is wire tests plus compilation.
  • A daily quota can strand earned tokens. If the balance is healthy but the
    quota is spent, the entry is hidden and the banked time expires at midnight
    unless carry_over = true. Documented with a CAUTION in
    config.example.toml; the guidance is to not put a quota on a gated entry.
  • Gating an entry and its group spends both balances for one session.
    Coherent, but hard to explain to a child; the docs steer toward gating at one
    level or the other.
  • No progress feedback for the child. A locked or spent activity simply
    disappears from the launcher. A locked tile showing progress is the obvious
    follow-up and would serve both features.

Docs

config.example.toml gains worked examples for both features and a "how this
interacts with the other time restrictions" section. Crate READMEs
(shepherd-config, shepherd-core, shepherd-store) cover the composition
rules, the combined-quota semantics, and the schema-migration constraint.
Design notes and as-built records are in docs/ai/history/2026-07-19 003 and
004.

Adds two related ways to shape when an activity is available, plus the management-app support to see and override them. Along the way it fixes a pre-existing break in the Android companion and replaces its hand-written wire mirrors with generated ones, so that class of break can't recur. Closes #8. Closes #5. ## Issue #8 — token system An activity can carry an `[entries.tokens]` block naming the activities whose time unlocks it. Time works as a currency: sessions on the source activities bank a balance on the target, and the target's own sessions spend it back down. ```toml [entries.tokens] from = ["tuxmath", "gcompris", "scummvm-putt-putt"] earn_ratio = 0.5 # two minutes earned banks one minimum_seconds = 600 # don't unlock for less than ten minutes max_balance_seconds = 3600 # don't bank more than an hour (0 = unlimited) carry_over = false # unspent time expires at local midnight ``` The session is capped at the banked balance, so it can never be overspent, and the activity re-locks once the balance is gone. The issue's other two bullets — manual and external-API conditions — are already covered by daily overrides and the management API, so only the time-based case is implemented. ## Issue #5 — grouped time limits A `[[groups]]` block defines a category that shares one schedule and one budget; entries join with `group = "<id>"`. ```toml [[groups]] id = "attention-heavy" label = "Games" [groups.limits] max_run_seconds = 900 # short bursts, per session, for any member daily_quota_seconds = 3600 # COMBINED across all members cooldown_seconds = 600 # any member's session cools down the whole group ``` The daily quota is the *combined* usage of the members, so one game can spend the category's whole budget and take its siblings down with it — the behaviour the issue asks for. A group cooldown is started by any member and applies to all of them, which is the main reason to set one: a per-entry cooldown is trivially dodged by starting a different game in the same category. Groups interoperate with tokens in both directions: `from = ["group:<id>"]` banks time from every member of a category, and `[groups.tokens]` gates a whole category so earning unlocks all of it at once. ### How the limits compose Every limit now exists at two levels and composes **strictest-wins**: a member must satisfy both its own limits and its group's, and the session is capped by the tighter of each. A group-level failure is reported as `GroupRestricted` wrapping the underlying reason, so a UI can say "Games: daily limit reached" and every future entry-level reason works at group level for free. | | Hides the entry | Caps the session | `availability = true` override bypasses | | --- | --- | --- | --- | | Availability window | yes | yes | yes | | Daily quota | yes | yes | yes | | Cooldown | yes | — | **no** | | Token gate | yes | yes | yes | | `max_run` | — | yes | no (only the daily quota is lifted) | ## Storage Cooldowns, token balances, and daily overrides are keyed by a `LimitSubject` rather than an entry ID, so a group holds the same state an entry does — including its own daily override. The key format is what makes this cheap: an entry subject renders as the **bare entry ID**, and only groups take a `group:` prefix. Every pre-existing row and API call is therefore already valid, and the migration is metadata-only — an `ALTER TABLE … RENAME COLUMN` guarded by a `PRAGMA table_info` check, with no row read or rewritten. `test_legacy_entry_keyed_tables_are_migrated_in_place` builds a real pre-groups database on disk and asserts the old cooldown and override survive. Entry and group IDs are both forbidden from starting with `group:`, which keeps the encoding unambiguous. ## ⚠️ Breaking change **`DailyOverride.entry_id` is now `DailyOverride.subject` on the wire.** The web UI, the Android companion, the dispatch tests, and the generated RPC schema are updated here. Any other consumer of that field needs the same change. Input is backward compatible — `upsert_override` still accepts a bare entry ID — but the response field is renamed. ## Management apps Groups were initially invisible to both apps, and auditing that turned up a pre-existing break in the companion. **Android repair.** The companion's `ReasonCode` mirror had drifted **four** variants behind the device: `not_ready` (#76) and `required_input_unavailable` (#96) were already missing before this branch added two more. kotlinx throws on an unknown polymorphic discriminator — `ignoreUnknownKeys` only covers unknown *fields* — and `reasons` is nested inside `EntryView`, so a single unrecognised reason failed the decode of the whole `list_entries` response. `DailyOverride` also still read `entry_id`, and `loadOverride` swallowed the resulting exception with `runCatching{}.getOrNull()`, making a failed load indistinguishable from "no override set" — the editor rendered a blank form over a real override, and saving would have silently overwritten it. **Both apps** now show a Categories section with combined-usage progress and override controls addressing `group:<id>`, and activity rows name their category. The web app's override mutations take a `LimitTarget`; Android's `OverrideSection` is keyed by subject — in both cases category and activity controls are the same code path. ## Generated wire types `RpcMethods.kt` was method-name constants only, and `ManagementClient` used raw string literals rather than those constants, so the drift check was guarding nothing that mattered. Neither Android break was catchable from the method schema, which records only *type names* from the trait signature. The wire types now carry `JsonSchema` derives, and a new `shepherd-wire-codegen` crate renders that schema into `WireTypes.generated.kt` — 38 declarations, doc comments carried across from Rust. The drift test covers that file, so a Rust field change fails CI until the Kotlin is regenerated. Every generated sealed interface gets an `Unknown` variant and a matching polymorphic default, which generalises the `ReasonCode` fix: any tagged enum can gain a variant, and an older companion degrades that one value instead of failing everything around it. The crate is a **leaf**, deliberately outside `default-members`. That placement does two jobs: it sits above `shepherd-ble`, so the BLE claim types generate too (a generator inside `shepherd-management` would be a dependency cycle); and it keeps `schemars` behind a `schema` feature that nothing shipped enables — `cargo tree -p shepherdd` shows no schemars at all. Generation immediately improved three types that had been hand-typed as strings: `RequiredInputUnavailable.devices` → `List<InputDeviceType>`, `AdminRecord.role` → `AdminRole`, and `DeviceInfo.protocolVersion` → `Long`. Three types stay hand-written, and the generator **panics** rather than emitting something subtly wrong if it meets their shapes: `LaunchOutcome` is externally tagged, and `Event`/`EventPayload` have a variant that flattens a `$ref` beside its tag, which kotlinx cannot express. ## Testing - **51 Rust test binaries**, `clippy --all-targets -- -D warnings`, `cargo fmt` - **23 Android JVM tests**, including wire tests for every previously-missing reason variant, subject-keyed overrides, and an unknown-code payload asserting it degrades without taking its siblings down - `tsc --noEmit` and a production web build - `config.example.toml` passes `shepherd config validate` - Codegen is idempotent; the drift guard was verified by probing it with a doc-comment change and watching CI-equivalent failure **End-to-end** via the headless dev session: - Tokens: a gated activity was absent from the launcher, appeared after a 127-second source session, and reported `max_run_if_started_now = 7620s` — 127 × the ratio of 60, exactly the banked balance — on an entry with no configured `max_run`. - Groups: both members of a category reported `max_run` clamped to the shared 120-second quota while an ungrouped activity stayed uncapped; one member's session then removed **both** members with two stacked `group_restricted` reasons (shared cooldown + exhausted combined quota); a `group:games` override cleared the quota reason, leaving only the cooldown, which nothing bypasses by design. - Web UI: verified visually against the real stack — the category card rendered "2m of 30m used today" with its members reading "Games: Cooling down". ## Known gaps - **The Android UI is not visually verified.** It needs an emulator and a paired BLE device; CI runs JVM unit tests only and the pair/claim path is a documented manual smoke test. Coverage there is wire tests plus compilation. - **A daily quota can strand earned tokens.** If the balance is healthy but the quota is spent, the entry is hidden and the banked time expires at midnight unless `carry_over = true`. Documented with a `CAUTION` in `config.example.toml`; the guidance is to not put a quota on a gated entry. - **Gating an entry *and* its group spends both balances** for one session. Coherent, but hard to explain to a child; the docs steer toward gating at one level or the other. - **No progress feedback for the child.** A locked or spent activity simply disappears from the launcher. A locked tile showing progress is the obvious follow-up and would serve both features. ## Docs `config.example.toml` gains worked examples for both features and a "how this interacts with the other time restrictions" section. Crate READMEs (`shepherd-config`, `shepherd-core`, `shepherd-store`) cover the composition rules, the combined-quota semantics, and the schema-migration constraint. Design notes and as-built records are in `docs/ai/history/2026-07-19 003` and `004`.
An activity can now be configured with an `[entries.tokens]` block naming the
activities whose time unlocks it. Time works as a currency: sessions on the
source activities bank a balance on the target, and the target's own sessions
spend it back down. The session is capped at the banked balance, so it can
never be overspent, and the activity re-locks once the balance is gone.

The issue's other two bullets — manual and external-API conditions — are
already covered by daily overrides and the management API, so this implements
only the time-based case.

Balances live in a new `token_balances` table keyed by entry, with an
`updated_day` column so a non-carrying balance resets lazily at local midnight
rather than needing a sweep job. The gate itself is one more check in
`evaluate_entry`, and the cap one more clamp in `compute_max_duration`, both
skipped under a force-enable override the same way the window and daily quota
already are. A session run under that override does not spend the balance:
the caregiver granted that time, so it isn't billed to the child.

Also fills in the web UI's TypeScript `ReasonCode` mirror, which was already
missing `not_ready` and `required_input_unavailable`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfw33ffZyMh4UTDSqfwNwr
Activities can now be grouped into a category with `[[groups]]` and
`group = "<id>"`. A group carries the full limit set — availability window,
per-session max_run, daily quota, and cooldown — shared by every member. The
daily quota is the *combined* usage of the members, so one game can spend the
whole category's budget and take its siblings down with it, which is what the
issue asked for.

Limits now exist at two levels and compose strictest-wins: a member must
satisfy both its own limits and its group's, and the session is capped by the
tighter of each. Group-level failures are reported as a `GroupRestricted`
reason wrapping the underlying one, so the UI can say "Games: daily limit
reached" and every future entry-level reason works at group level for free.

A group cooldown is started by any member's session and applies to all of
them, which is the main reason to set one: a per-entry cooldown is trivially
dodged by starting a different game in the same category.

Groups also interoperate with the token system (#8) in both directions. A
`group:<id>` entry in `tokens.from` banks time from every member of a
category, and `[groups.tokens]` gates a whole category so earning unlocks all
of it at once.

Cooldowns, token balances, and daily overrides are now keyed by a
`LimitSubject` rather than an entry ID, so a group holds the same state an
entry does — including its own daily override, letting a caregiver enable or
disable a whole category with one call. An entry subject's string form is its
bare ID and only groups take the `group:` prefix, so every stored row and API
call written before groups existed stays valid; `rename_legacy_key_column`
only renames the stale `entry_id` column and never reads or rewrites a row.
Entry and group IDs are forbidden from starting with `group:` to keep that
encoding unambiguous.

BREAKING: `DailyOverride.entry_id` is now `DailyOverride.subject` on the wire.
The web UI, the dispatch tests, and the generated RPC schema are updated here;
any other consumer of that field needs the same change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfw33ffZyMh4UTDSqfwNwr
The companion's `ReasonCode` mirror had drifted four variants behind the
device: `not_ready` (#76) and `required_input_unavailable` (#96) were already
missing before the token and group work added `tokens_insufficient` and
`group_restricted`. Because `reasons` is nested inside `EntryView` and an
unknown polymorphic discriminator throws — `ignoreUnknownKeys` only covers
unknown *fields* — a single unrecognised reason failed the decode of the whole
`list_entries` response, so the caregiver got a raw serializer message and an
empty activity list rather than one oddly-labelled row.

Registering `ReasonCode.Unknown` as the polymorphic default fixes the class of
bug rather than this instance: a companion talking to a newer shepherdd now
degrades that reason to "Unavailable" and renders everything else.

`DailyOverride` also still read `entry_id`, which became `subject` when limits
gained group-level subjects. That field has no default, so every override
lookup threw `MissingFieldException` — and `loadOverride` swallowed it with
`runCatching{}.getOrNull()`, which made a failed load indistinguishable from
"no override set". The editor rendered a blank form over a real override, and
saving from it would have silently overwritten one. It now returns a `Result`,
surfaces the error, and disables Save/Clear so a caregiver can't act on state
the app failed to read.

Adds wire tests for all four previously-missing variants, the nested
`group_restricted` shape, subject-keyed overrides for both an entry and a
group, and an unknown-code payload asserting it degrades without taking its
sibling reasons down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfw33ffZyMh4UTDSqfwNwr
Groups were invisible to the management apps: `EntryView` carried no group,
there was no way to ask what categories exist, and every override control
addressed an entry. A caregiver could see that Celeste was unavailable but not
that it shared a budget with three other games, and could only re-enable the
category by overriding each member in turn.

Adds `group` to `EntryView` and a `GroupView` / `list_groups` RPC reporting
what a category needs that no single member can answer: its members, its
combined usage against the combined quota, the longest session its limits
allow, and whatever is currently restricting it — with the reasons unwrapped,
since `GroupRestricted` is only meaningful when it wraps a member's view.

The web UI gains a Categories section above Activities: usage against the
shared budget as a progress bar, the current restriction, and the same
Enable/Disable Today and quota-delta controls the activity cards have. Those
four mutations now take a `LimitTarget` — a subject plus a label — so the
category cards drive exactly the same code path, differing only in whether the
subject is a bare entry id or `group:<id>`. Activity cards show a chip naming
their category, so it's obvious why one can vanish because a sibling was
played.

Verified against the real stack: a category with a spent budget and an active
cooldown renders "2m of 30m used today" with its members reading
"Games: Cooling down", while an ungrouped activity is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfw33ffZyMh4UTDSqfwNwr
Mirrors the web app's category support in the companion. `GroupView` and a
`list_groups` call give the app the shared state — combined usage, the quota it
counts against, the cap the category imposes, and what is currently blocking it
— which no individual activity's screen can explain.

Home gains a Categories section above Activities, and each row opens a detail
screen with the shared limits, the member list, and an override editor
addressing `group:<id>`. Activity rows now name their category, so it's obvious
why one can become unavailable because a sibling was played.

`OverrideSection` moves out of the entry screen into `ui/override/` and is
keyed by limit subject rather than an entry id, so the entry and group screens
share one editor. Group state is refreshed alongside entry state: a pushed
StateChanged carries entries but not categories, and a member's session spends
the shared budget and can start a category-wide cooldown.

Adds wire tests for GroupView (including the derived `group:<id>` subject) and
for `EntryView.group`, which must stay absent-tolerant so the app keeps working
against a device predating groups.

Not visually verified: the companion needs an emulator and a paired BLE device,
and CI runs JVM unit tests only. Coverage here is the wire tests plus
compilation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfw33ffZyMh4UTDSqfwNwr
Groundwork for generating the companion's Kotlin mirrors instead of hand-
writing them. The RPC schema records only *type names* from the trait
signature, which is why it could not catch either of the drifts that broke the
phone: four missing `ReasonCode` variants and a renamed `DailyOverride` field.

Derives `JsonSchema` across the wire types and adds `wire_schema()`, which
emits the full field-level shape of every type reachable from the management
API — 35 of them — keyed by Rust type name.

The aggregator lives in shepherd-management rather than shepherd-api so it can
also see `LaunchOutcome`, which is defined here. Note this still cannot reach
`AdminRecord` / `DeviceInfo` / `ClaimStateTag`: those live in shepherd-ble,
which depends on shepherd-management, so pulling them in would be a dependency
cycle.

The string-shaped newtypes need `#[schemars(...)]` help, since their schema
must follow their hand-written codecs rather than their Rust shape:
`LimitSubject` is a `String` (an enum in Rust, `<entry-id>` / `group:<id>` on
the wire), and the ID newtypes are transparent.

Tests assert the schema covers the types that previously drifted and every
`ReasonCode` variant, so the source of truth for the generator is itself
guarded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfw33ffZyMh4UTDSqfwNwr
The companion's payload types were hand-written mirrors of the Rust ones, and
they drifted twice — four missing `ReasonCode` variants and a renamed
`DailyOverride` field — each time breaking the phone in a way no test caught.
`RpcMethods.kt` couldn't help: it is method-name constants only, and
`ManagementClient` doesn't even use those, so the drift check was guarding
nothing that mattered.

Renders the wire JSON Schema into `WireTypes.generated.kt`: data classes,
enums, and tagged sealed interfaces for 32 types, with doc comments carried
across from the Rust originals. The drift test now covers that file, so a Rust
field change fails CI until the Kotlin is regenerated — verified by probing it
with a one-line doc edit.

Every generated sealed interface gets an `Unknown` variant and a matching
`polymorphicDefaultDeserializer` in `ShepherdWireModule`. That generalises the
fix that was applied to `ReasonCode` by hand: any tagged enum can gain a
variant, and a companion talking to a newer device now degrades that one value
rather than failing the decode of the whole response.

Three types stay hand-written, and the generator panics rather than emitting
something subtly wrong if it meets their shapes: `LaunchOutcome` is externally
tagged, and `Event`/`EventPayload` have a variant that flattens a `$ref`
beside its tag, which kotlinx cannot express. The BLE claim types are
unreachable for a different reason — they live in shepherd-ble, which depends
on shepherd-management, so the generator cannot see them without a cycle.

Generation also improved one type: `RequiredInputUnavailable.devices` was
`List<String>` by hand and is now `List<InputDeviceType>`, which the existing
wire test caught by way of the display text changing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfw33ffZyMh4UTDSqfwNwr
refactor(codegen): move wire codegen into a leaf crate
All checks were successful
CI / Version harmony (pull_request) Successful in 32s
CI / ShellCheck (pull_request) Successful in 39s
CI / Arch neutrality (pull_request) Successful in 37s
CI / CI image (pull_request) Successful in 10m58s
CI / CI image (Android) (pull_request) Successful in 7m18s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 45s
CI / Warm cargo registry (pull_request) Successful in 1m8s
CI / Android companion (unit tests) (pull_request) Successful in 2m16s
CI / Firewall E2E (pull_request) Successful in 4m58s
CI / Android media (cargo-ndk build) (pull_request) Successful in 6m23s
CI / Clippy (pull_request) Successful in 6m27s
CI / Build (pull_request) Successful in 15m27s
CI / Package (.deb smoke build) (pull_request) Successful in 10m23s
CI / E2E (pull_request) Successful in 17m0s
CI / Test (pull_request) Successful in 20m55s
590e44025b
Two things the previous arrangement couldn't do, both fixed by where the code
lives rather than by what it does.

The generator sat in shepherd-management, so it could not see the BLE claim
types — shepherd-ble depends on shepherd-management, and reaching the other
way would be a cycle. `AdminRecord`, `DeviceInfo`, `ClaimStateTag` and
`AdminRole` were left hand-written for that reason alone. From a leaf crate
above shepherd-ble they generate like everything else, so the last of the
hand-written mirrors are gone.

And schemars was an unconditional dependency of shepherd-api, so it compiled
into shepherdd and the launcher to serve a build-time need. The derives are now
behind a `schema` feature that only shepherd-wire-codegen enables. Feature
unification would still drag it in if the codegen crate were built alongside
everything else, so it is excluded from `default-members`: a plain
`cargo build` at the repo root skips it, and `cargo tree -p shepherdd` now
shows no schemars at all.

`cargo test --workspace` still reaches it, so the drift guard runs in CI —
verified by probing a BLE doc comment and watching the check fail until
regenerated.

Two more type improvements fell out of generating the claim types:
`AdminRecord.role` is now the `AdminRole` enum rather than a bare String, and
`DeviceInfo.protocolVersion` is a Long matching the Rust u32, so
`Protocol.PROTOCOL_VERSION` widened to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lfw33ffZyMh4UTDSqfwNwr
fix(core): stop token gates from billing granted sessions or stranding time
All checks were successful
CI / Version harmony (pull_request) Successful in 34s
CI / ShellCheck (pull_request) Successful in 39s
CI / Arch neutrality (pull_request) Successful in 37s
CI / CI image (pull_request) Successful in 40s
CI / CI image (Android) (pull_request) Successful in 24s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 43s
CI / Warm cargo registry (pull_request) Successful in 47s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m38s
CI / Android companion (unit tests) (pull_request) Successful in 2m23s
CI / Clippy (pull_request) Successful in 2m17s
CI / Build (pull_request) Successful in 5m23s
CI / Firewall E2E (pull_request) Successful in 6m20s
CI / E2E (pull_request) Successful in 5m18s
CI / Test (pull_request) Successful in 6m13s
CI / Package (.deb smoke build) (pull_request) Successful in 3m10s
1c422f085f
Four defects found while validating groups (#5) and tokens (#8) end to end
against the Android companion, on a real phone over BLE. Notes and screenshots
in docs/ai/history/2026-07-20 001 groups-tokens-companion-validation.md.

A force-enable at one level lifted the gate and the clamp at the other, but the
spend exemption only covered the same subject. So a caregiver enabling a
category for the day approved an uncapped session on a member and then billed
that member's own balance for it — 152s banked, an hour approved, balance
drained to zero. The mirror case billed a category's shared balance for a
session granted on one member, locking out its siblings. `settle_tokens` now
derives one `granted` flag from the same expression `evaluate_entry` uses.

`minimum_seconds` was re-checked on every evaluation while the clamp allowed
spending the whole balance, so a short session re-locked the activity and
stranded the rest: 76s banked, 22s played, 54s left unusable and destroyed at
midnight. It is now a threshold to cross — the gate ratchets open and stays
open until the balance is spent to zero. The flag lives beside the balance in
`token_balances.ratcheted`, set by the engine (the only place that knows the
threshold), cleared by the store at zero, and expiring with the balance.

`token_balances` was missing from the legacy key-column migration it shipped
one commit ahead of, so on any database from that commit every `WHERE subject`
read failed, the engine's `unwrap_or(ZERO)` turned that into an empty balance,
and every gate locked with no error anywhere. Reproduced against a hand-built
legacy database. The table is migrated now, the swallowed store errors are
logged, and shepherd-store's README says the rule out loud.

`list_groups` clamped by window, quota and balance unconditionally, so a
force-enabled category reported a 0s cap while its members ran at 120s — the
companion rendered that literally as "Up to 0s per session".

Covered by 4 engine tests (both exemption directions, the ratchet and its
release, the group cap under an override) and 3 store tests (ratchet
persistence, its midnight reset, and a legacy token_balances table added to the
existing migration test). Re-verified on the device: the partial spend leaves
the category unlocked, neither override spends a balance, and the group screen
reads "Up to 2m per session".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L88yZ3XWzAR8TihyDq8jcP
A member of a category can be held back by several limits at once — a group
cooldown and a spent group quota, say — but both management apps rendered only
`reasons[0]`. Clearing the first then revealed a second reason the caregiver had
never been shown, which reads as though the limit moved. A shared `ReasonLines`
composable now renders all of them on the home rows, the group card and the
entry detail card, replacing three copies of the same one-line lookup; the web
UI's activity card joins them the way its category card already did.

Rendering every reason exposed a pre-existing wart: the activity that is
*currently running* carries `SessionActive` against itself, so its own row
advertised "Another activity is running". Suppressed for the in-session row
rather than left as a visible regression.

The entry detail screen also named no category and offered no way to reach one,
even though the category screen is where a shared limit can actually be
inspected or overridden — and is often what is blocking the activity. It now
shows a chip that navigates there.

And a blocked category read "Up to 0s per session". That is the true cap, but it
reads as a limit rather than as "not right now", so the line is shown only while
the category is available and the reasons below carry the message.

Verified on the phone over BLE against a fixture with a category blocked by both
a cooldown and an exhausted quota: both lines render on each member and on the
category screen, the chip navigates, and the running activity's row is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L88yZ3XWzAR8TihyDq8jcP
feat(api): let a caregiver grant banked time, and show what's banked
All checks were successful
CI / Version harmony (pull_request) Successful in 42s
CI / Arch neutrality (pull_request) Successful in 40s
CI / ShellCheck (pull_request) Successful in 43s
CI / CI image (pull_request) Successful in 44s
CI / CI image (Android) (pull_request) Successful in 24s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 50s
CI / Warm cargo registry (pull_request) Successful in 57s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m39s
CI / Android companion (unit tests) (pull_request) Successful in 2m25s
CI / Clippy (pull_request) Successful in 2m43s
CI / Build (pull_request) Successful in 5m33s
CI / Firewall E2E (pull_request) Successful in 6m51s
CI / E2E (pull_request) Successful in 5m31s
CI / Test (pull_request) Successful in 6m22s
CI / Package (.deb smoke build) (pull_request) Successful in 3m4s
a2ee855eab
The token system (#8) shipped with no way to see a balance and no way to move
one. A caregiver could tell that an activity was locked but not whether it was
a minute short or an hour, and the only lever was an availability override,
which is a different thing: it switches the activity on for the day rather than
paying for it.

`adjust_tokens {"id": "<subject>", "delta_seconds": N}` moves the stored
balance for an entry or a `group:<id>`, and reports the gate back. The
alternative — a `token_delta_seconds` beside `quota_delta_seconds` on
DailyOverride — was rejected: a delta applied at read time fights the spend
path, because the stored balance decrements underneath it, and with
`carry_over = true` the delta would expire at midnight while the balance it
modified survives. Moving the balance itself is what the engine already does
when a source activity earns.

Granted time is deliberately indistinguishable from earned time: capped by
`max_balance_seconds`, spent by the gated activity's own sessions, and opening
the gate only once the balance reaches `minimum_seconds`. A grant is not a
bypass — that is what the availability override is for — so 5 minutes granted
against a 20-minute threshold banks 5 minutes and unlocks nothing. Adjusting a
subject with no `[tokens]` block is refused (`Unprocessable`) rather than
silently writing a balance nothing reads.

`EntryView` and `GroupView` now carry `tokens: Option<TokenStatus>` — balance,
minimum, unlocked, ceiling, carry-over — so both apps can show progress toward
a gate instead of only that it is shut. A member of a token-gated group has no
gate of its own; the category's rides on the GroupView, where the shared budget
belongs. Android gets an "Earned time" card with a ±5 min stepper on the entry
and category screens, the web UI an equivalent inline row on both cards.

Verified against the phone over BLE: a grant unlocked a category and its
members, the stepper moved the balance 300 → 600 s with the audit row to match,
and a revoke re-locked it. The web row is typechecked but not visually checked —
this box has no Chromium and the Firefox snap won't start inside the kiosk
session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L88yZ3XWzAR8TihyDq8jcP
Bump version to 0.3.0
All checks were successful
CI / Version harmony (pull_request) Successful in 53s
CI / Arch neutrality (pull_request) Successful in 50s
CI / ShellCheck (pull_request) Successful in 56s
CI / CI image (pull_request) Successful in 53s
CI / CI image (Android) (pull_request) Successful in 25s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 1m18s
CI / Warm cargo registry (pull_request) Successful in 1m53s
CI / Firewall E2E (pull_request) Successful in 4m22s
CI / Android media (cargo-ndk build) (pull_request) Successful in 5m5s
CI / Android companion (unit tests) (pull_request) Successful in 5m7s
CI / Clippy (pull_request) Successful in 4m3s
CI / Build (pull_request) Successful in 7m48s
CI / Test (pull_request) Successful in 8m33s
CI / E2E (pull_request) Successful in 7m45s
CI / Package (.deb smoke build) (pull_request) Successful in 8m46s
d23713743d
albert merged commit cc0c5db7af into main 2026-07-26 01:25:55 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
albert/shepherd-launcher!107
No description provided.