Compare commits

..
Author SHA1 Message Date
jschoubben 15db34169e distribution: the holder's test follows the rule that a chunk 0 again starts a part afresh
mesh/delivery ready: it is merged on the Approve
mesh/delivery-group group fix/1975-kernel-log checking: 2 of 2 member(s) ready
mesh/check checking 15db34169e24 on d9f2b7bcfa07 | novox/mesh-catalog#658 in group fix/1975-kernel-log
mesh-delivery's parts.Receiver.Take took a chunk 0 again on purpose (276412d7, design 51 Publishing, 1966/AC203-1,
AC203-2); the holder's test still expected it refused and failed on every run, on trunk too. The test now expects a
chunk out of sequence refused and a chunk 0 again taken.

Builds: design 51 Publishing, the bullet "The file travels in parts through the seat"; issue 1975's group check

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 03:04:00 +01:00
jschoubben ebcc7761f1 journal refuses a boot argument it does not use (issue 1975, AC4); tests cite their acceptance labels
mesh/check failed (2): unit: distribution/internal/holder.TestTheHolderOfTheTwoImageSeats/a_transfer_in_par…; and 1 more | ebcc7761f178 on d9f2b7bcfa07
mesh/delivery-group group fix/1975-kernel-log rejected: composed together: the checker's verdict at the members' heads (c265ee221ccf): unit: distribution/inter…
mesh/delivery closed: a newer head of the same pull request
Builds: issue 1975 note 2: lead answer L4, acceptance 1975/AC1-AC4

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:24:44 +01:00
jschoubben 3ebcb15a4b systemd: serve kernel-log, the kernel's messages of one boot (issue 1975)
journalctl --dmesg --boot=N under the same escalation, window, priority, fixed-string match, 2000-line cap
and redaction as the journal verb (shapeLines is the one filter both use); a boot is 0 or a negative whole
number, refused otherwise before anything runs. No scope: the kernel belongs to no manager.

Builds: issue 1975, the operator's request of 2026-11-12 (the lead's handover; no design line names the verb)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 02:17:28 +01:00
mesh-tools d9f2b7bcfa Merge pull request 'The licence manager keeps its data on the bus and switches licences by itself at 99% (issue 1409)' (#657) from feat/39-licence-switch into main 2026-11-12 00:13:04 +00:00
jschoubben dbcba161d5 M-6 fix: the carry-over carries each live lease with its remaining lifetime (issue 1409)
mesh/delivery-group group feat/39-licence-switch merged: every member is merged
mesh/check checking dbcba161d57c on d9f2b7bcfa07
mesh/delivery merged: source.main moved to novox/mesh-catalog@d9f2b7bcfa07
The lead's answer B44: a lease the bundle before held in today's database
and still live at the run is written to `leases` under the bus's key form
with what is left of its lifetime, so no refresh races it whatever the
delivery's order; an expired one is counted read and not written.

Builds: design 39 bus-and-switch §4, §12, B44

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:07:09 +01:00
jschoubben c8e321c8e1 M-11 fix: a reading takes its licence's lease, and a grant the vault does not answer is a failed reading (issue 1409)
Review round 1, finding 5: the reading reads the grant under the licence's
lease (another run holding it reads nothing that round), and a grant the
vault refuses writes readError, keeps the percentages and goes on to the
next licence, so the conditions are still said after the round.

Review round 1, finding 6: these answers were built by earlier commits that
did not name them: B26, B27, B28 and B29 by M-6 (9cc62818), B30 by M-9
(39912119), B35 by M-11 (6cf98386); B23 and B24 by V-1 (e85cf002) and M-7
(5ca3876b), named again in b8e8be61; B31 and B32 in 36d9d739.

Builds: design 39 bus-and-switch §4, §16, §24, §31, B23, B24, B26, B27, B28, B29, B30, B34, B35

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:04:07 +01:00
jschoubben b8e8be619d M-15 fix: resync hands the vault each access token built from its grant (issue 1409)
Review round 1, finding 4: the vault hands a licence's access token only to
a machine a binding names, so licences_resync builds it from the grant (the
key itself for an API key) instead of reading it back.

Builds: design 39 bus-and-switch §15, §17, §42, B23, B24

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:02:54 +01:00
jschoubben cc8a354f75 M-3 fix: an adopted licence says where it came from, and a newer login keeps its reading (issue 1409)
Review round 1, finding 3: adoptedFrom is the node a login came from, or
where an API key came from; a newer login of the same account keeps the
latest reading and generation the key holds.

Builds: design 39 bus-and-switch §2, §34, §46

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:02:14 +01:00
jschoubben 6a57bb7218 M-6 fix: each carried binding is said as binding.changed (issue 1409)
Review round 1, finding 2: a reader's first view taken while the carry-over
runs is brought up to date by the event said after each key is written.

Builds: design 39 bus-and-switch §12, §21

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:01:40 +01:00
jschoubben 36d9d73984 V-3 fix: no access token before the first view is answered, and a first view keeps a newer event's key (issue 1409)
Review round 1, finding 1, and the lead's answer B43. Until the vault's first
view of the licence bindings is answered since its start, licence-put's
access token is refused, saying why. A first view removes a consumer's key it
does not name only when that key was written before the view was asked. The
newer generation's binding staying over a first view (B32) was built in V-2's
keep and is named here.

Builds: design 39 bus-and-switch §17, B31, B32, B43

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 01:00:42 +01:00
jschoubben 9cf5c1ddbd V-4: the vault's first-view tools cover the licence bindings, and its README says the licence secrets (issue 1409)
vault_first_views lists the licence bindings' first view beside the
assignments'; vault_first_view_again asks the one its new argument `of`
names, and both when it is absent.

Builds: design 39 bus-and-switch §52, §15 to §17, B21, B42

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:42:53 +01:00
jschoubben 3b92e02862 C-4: the agent module's README says it follows its binding by event and takes its token from the vault (issue 1409)
Builds: design 39 bus-and-switch §52, §19, §22, §23, B33

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:39:49 +01:00
jschoubben a8131809b2 M-17: the manager's README says what it keeps on the bus and how it switches (issue 1409)
Builds: design 39 bus-and-switch §52, §1 to §13, §21, §23 to §46

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:39:21 +01:00
jschoubben b8ce072c9f M-16: the manager whole on a real bus and today's database on a real postgres (issue 1409)
The built binary played as its runtime plays it, its kept data in a real
nats-server's buckets and streams, today's rows in a real postgres: a start
stopped at a table says carry-over-failed and acts on nothing, the next
carries every row and a further one writes nothing; an unreachable database
calls no vendor; a reading at the threshold moves four consumers in one act
and no-room is raised and cleared on the operator channel's subject, read
through its client; a new login is adopted, listed and a target.

Builds: design 39 bus-and-switch §9, §11, §12, §13, §23, §26, §28, §29, §46, B41

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:37:54 +01:00
jschoubben baaf2e56dd M-15: the manager's tools read all it keeps and start by hand all it does (issue 1409)
The licences verb answers each licence's organisation, latest usage with its
reset times, its room or why it is not known, whether it is where a full
licence's consumers move next, when and from where it was adopted and its open
conditions. The usage verb takes since, a limit of at most 2000 and raw. The
module's tools: licences_switches from the audit, licences_read_usage and
licences_switch_now with dry, licences_settings with the level mesh-config
answers (the manifest invokes its config verb), licences_kept over every
bus-state entry with its fill, licences_resync, licences_conditions and
licences_carried with its run, refused once carried. An acting tool waits for
the carry-over.

Builds: design 39 bus-and-switch §34, §35, §37, §38, §39, §40, §41, §42, §43, §44, §47, §20, §11, B9, B18, B40

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:19:22 +01:00
jschoubben 9e545658a5 M-14: the manager's conditions, kept in its state and said on the operator channel (issue 1409)
Each condition the manager holds is written in its state conditions with its
body and since, then said on the operator channel's condition accept under
claude-licence-manager, scope module; it is said again only when its words
change, and said cleared, its key deleted, once it no longer holds. At its
start, after the carry-over's run, it judges every kind anew: raised again
each it holds, cleared each kept key it no longer holds. The kinds:
carry-over-failed, settings.<setting>.invalid for a pair above
switch_at_pct and for a setting outside its bound, and per licence no-room
when the rule finds no target, near-limit at warn_at_pct with consumers, and
usage-unread once its latest success, or its adoption when none, is older
than usage_stale_minutes. Headlines name counts and short names, cut at 60.

Builds: design 39 bus-and-switch §9, §11, §29, §30, §31, §33, B17, B34, B36, B37, B38, B39

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:13:05 +01:00
jschoubben 4e10ab7b2e M-13: a licence at its threshold moves every consumer, in one act (issue 1409)
After each reading of a licence with consumers whose session or weekly
window is at or above switch_at_pct, the manager moves every consumer bound
to it, when switch_auto is on and the settings are valid, to the other
licence with known room whose windows are both below room_below_pct and the
most room, the first by name on a tie. Each consumer moves as the switch verb
does, by usage, and says binding.changed; the act says one licence.switched
and one audit entry switched with the window, its percentage and reset and
the target's room, and is told to the operator; the switch verb is not told.
By hand the rule runs with switch_auto off, and dry answers the moves alone.

Builds: design 39 bus-and-switch §26, §27, §28, §32, §33, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-12 00:00:19 +01:00
jschoubben 954e3afe56 M-12: the switch's settings, read again at every reading (issue 1409)
The settings gain switch_auto, switch_at_pct, room_below_pct, warn_at_pct,
usage_minutes and usage_stale_minutes with the manifest's defaults and
bounds, a value outside its bound keeping the default. The settings file is
read again at every reading, each changed value written in the audit under
settings, and the usage timer follows usage_minutes. A room or warning above
switch_at_pct is found as an invalid pair naming both values.

Builds: design 39 bus-and-switch §33, §24

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:57:38 +01:00
jschoubben 6cf98386e9 M-11: each reading keeps its three reset times, and a licence's room (issue 1409)
The usage reading flattens the session, weekly and sonnet windows each with
its reset time, keeps it beside the licence with when it was read, and says
it as usage.read; a failed answer keeps the last percentages and writes the
vendor's status and reason. A licence's room is 100 less the higher of its
session and weekly windows, known only for a subscription read within
usage_stale_minutes whose refresh is healthy; an API key's is never known.

Builds: design 39 bus-and-switch §24, §25, §48, §2

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:55:22 +01:00
jschoubben bd65b023bd M-10: the manager hears what each node holds as holdings-changed (issue 1409)
The manager consumes the agent module's holdings-changed, keeping each report
as its node's latest and considering them at once, in place of reading the
agent module's state; once it hears them, at every start after its
carry-over, it says holdings.asked with its time and who asks, so every node
says what it holds again.

Builds: design 39 bus-and-switch §23, §46, B12, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:53:10 +01:00
jschoubben 31ce47d1cc C-3: a node takes its licence token from the vault (issue 1409)
The agent module asks the seat's current which licence it holds and its key,
then the vault's get for that licence's access secret sealed to its own key:
at its start, on a binding.changed with a newer generation than it applied,
and on the vault's secret-changed for the licence it holds. The applied
binding keeps the licence's key, so the vault's event is matched by the
carried key, never one formed here.

Builds: design 39 bus-and-switch §19, §18, B7, B9, B11

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:51:00 +01:00
jschoubben 6eccfc286a C-2: a node follows its licence binding by binding.changed and a first view (issue 1409)
The agent module consumes the manager's binding.changed, acting only on the
one naming its node's consumer, and each time it hears those events anew asks
the seat's bindings for its own consumer as its first view; its read of the
manager's bindings state goes.

Builds: design 39 bus-and-switch §22, B9, B10, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:44:12 +01:00
jschoubben 110c631b5f V-3: a licence's token goes only to a machine a binding names (issue 1409)
The vault's get judges a licence's secret by a rule of its own, before the
asker rule: a grant goes to any proven caller, and an access token to a
machine on which a consumer's binding in licence-bindings names that
licence's key; a refusal names the rule and is recorded.

Builds: design 39 bus-and-switch §16, §17, A1, B3, B8, B25

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:42:11 +01:00
jschoubben 256a412590 V-2: the vault keeps the licence bindings from binding.changed and a first view (issue 1409)
The vault consumes the licence manager's binding.changed and, at its start,
asks the seat's bindings until it answers; it keeps each consumer's licence,
the licence's key and the generation in its own state licence-bindings,
never an older generation, deleting a released consumer's key. The state is
in the vault's old state section, and holds no secret.

Builds: design 39 bus-and-switch §17, B2, B9, B10, B22, B25

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:39:15 +01:00
jschoubben b63e8ae58a M-8: the bindings verb answers each licence's key (issue 1409)
Each binding the verb bindings answers carries its licence's key beside the
§3 fields, as binding.changed does, so the vault's first view matches a
token's secret name to the binding without the key form of its own.

Builds: design 39 bus-and-switch §17, B10, B25 (hq 128236e7 and after, PR #561)

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:32:31 +01:00
jschoubben 3991211993 M-9: every change of a binding is said as binding.changed (issue 1409)
Bind, switch, release, a login's move and a rotation's advance each say one
binding.changed per consumer moved, with the licence's key, after the key of
bindings is written or deleted; a switch through the verbs says one
licence.switched with the from, to, consumers, by and why the audit's
switched entry also holds. licence.bound, released and rotated are said no
more. An adoption advances the consumers already bound before it moves the
login's nodes, so each is said once.

Builds: design 39 bus-and-switch §21, §27 (the verb's switch), §36, B7, B16

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:30:39 +01:00
jschoubben 9cc62818f4 M-6: today's rows carried over once, before the manager does anything else (issue 1409)
At its first start the manager reads the six tables of its database, read
only, and writes them into its kept data and its grants into the vault at
generation 1, then carried.postgres; until then only its read verbs answer.
A second start never opens the database, and a stopped run is done again
whole without doubling a log entry. The store's write code and its postgres
test go: nothing writes the database again.

Builds: design 39 bus-and-switch §1, §8, §11, §12, §13, A3, B14

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:28:14 +01:00
jschoubben 2b604e0dad M-5: the usage and audit logs kept in the bus's own storage
Every reading is appended under its licence's key with the vendor's answer whole (ADR
0054), and every act under its kind, never rewritten; an act whose audit entry the log
does not take fails and says so. The memory store goes: the tests run the bus store over
a bus kept in memory.

Builds: design 39 bus-and-switch §6, §7, B20

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:19:03 +01:00
jschoubben 9770dab09e M-4: leases and offered grants kept in the bus's own storage
A lease is a key created with a two-minute lifetime, which fails while another holds it:
the bus's own compare-and-set keeps one rotation source (ADR 0183 §6). An offered grant's
outcome is kept under its fingerprint in the key form for 90 days, every outcome kept.

Builds: design 39 bus-and-switch §4, §5, B13

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:17:16 +01:00
jschoubben 15094d6e72 M-3: licences and bindings kept in the bus's own storage
The state licences holds one key per licence by its key form, never a secret, with its
latest reading beside it; the state bindings, the same local name the consumers read,
holds each consumer's licence, kind, generation, since, why and by, written by the store
itself. Each consumer counts its own generations, past what its node reported.

Builds: design 39 bus-and-switch §2, §3, B11, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:16:25 +01:00
jschoubben 5ca3876bea M-7: every grant and access token lives in the vault
The manager hands what the vendor answers to licence-put, the grant first and then the
access, at the licence's next generation, and reads a grant with get for one act; it keeps
no token at rest (ADR 0550 §3). The sealing at rest goes; opening stays for the carry-over.

Builds: design 39 bus-and-switch §15, §16, §20, §45, B15

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:13:31 +01:00
jschoubben f29a9ad563 M-8: current answers the licence and its health, never the token
The vault hands every token (ADR 0550 §3); current names the licence, its key, kind,
generation, health and access expiry, and keeps the account's identity for the agent's
account file. bindings takes an optional consumer, answered unpaged.

Builds: design 39 bus-and-switch §18, B7, B10, B19

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:11:09 +01:00
jschoubben 4179be7a56 C-1: a node's holdings are said as holdings-changed, and again when asked
The licence manager follows the nodes' holdings by an event and asks for them with
holdings.asked, instead of reading the agent module's state (ADR 0596 §2). The state
holdings stays the module's own.

Builds: design 39 bus-and-switch §23

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:09:22 +01:00
jschoubben e85cf002a4 V-1: the vault serves licence-put, keeping the caller's newest generation
A licence's grant and access token move into the vault (ADR 0550 §3), handed in two steps
with a key made for the call, as move-in does; the caller's generation is the record's,
an older one is refused, and secret-changed names the secret and its generation.

Builds: design 39 bus-and-switch §15, B3, B4, B5, B6

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:07:44 +01:00
jschoubben 1c03299721 M-2: the manager's manifest asks for its kept data by template
Eight bus-state entries in the typed long form; the old state and the reads of
claude-code.holdings go, and so does the run-once prepare, since nothing writes the
tables again. The database and grant-key stay as the carry-over's read-only source.
The manifest declares its new events, its consumption of holdings-changed, the
operator channel it uses, the verbs it invokes, six settings and its eight tools.

Builds: design 39 bus-and-switch §1, §9, §21, §23, §27, §33, §37-§44, A3, B9, B16

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 23:01:43 +01:00
jschoubben e995e40519 M-1: a licence's key form, and an adoption whose key is taken is refused
A key of the bus takes fewer characters than a licence's name; the key is the store's
alone, and two names that write the same key would overwrite each other.

Builds: design 39 bus-and-switch §10, B6

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
2026-11-11 22:58:52 +01:00
mesh-tools 9dfb2eae0e Merge pull request 'Registry recovery: the bus module's list, show and orphans answer each binding (issue 1966)' (#656) from fix/1966-bindings-first-view into main 2026-11-11 10:32:25 +00:00
jschoubben ae056a44dd The bus module's list, show and orphans answer each held entry's binding
mesh/delivery merged: source.main moved to novox/mesh-catalog@9dfb2eae0eb3
mesh/delivery-group group fix/1966-bindings-first-view merged: every member is merged
mesh/check checking ae056a44dd72 on 9dfb2eae0eb3 | novox/mesh-catalog#656 in group fix/1966-bindings-first-view
Held.binding is the entry's plan binding, the value its created and recreated
carry, present when the bus holds the entry's objects and absent on one it does
not (waiting, refused). Table rows in the reading verbs' test: the binding of a
held entry equals the one its created carries; a waiting entry has none.

Builds: design 25 bindings-first-view §2, §7 (hq d0760629, PR 543)

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 10:19:42 +01:00
mesh-tools ea74be9e2e Merge pull request 'The Go fetch fails when its shut-off package load cannot run at all, naming the directory and go list's error (issue 1966)' (#655) from fix/1966-shutoff-load-loud into main 2026-11-10 15:42:10 +00:00
jschoubben 19994c1817 The fetch fails when the shut-off package load cannot run at all, naming the directory and go list's error (issue 1966, AC182-1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@ea74be9e2e10
mesh/check checking 19994c1817bb on ea74be9e2e10
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 16:37:40 +01:00
mesh-tools 3c0b4d29a8 Merge pull request 'Fill gitea's pool keys in the provisioner client's gitea tests, which fail on main since 4becc7bd (issue 1966)' (#653) from fix/1966-provisioner-client-pool-config into main 2026-11-10 14:49:01 +00:00
mesh-tools 7cc81512a3 Merge pull request 'The Go fetch takes zero-version requirements from the module graph and proves them with a shut-off load; mesh-build-image and -node name nodebuildgo (issue 1966)' (#654) from fix/1966-fetch-module-graph into main 2026-11-10 14:48:41 +00:00
mesh-tools 462db1c257 Merge pull request 'Play the config watch in the package seats' whole test, so the held callers list is proven (issue 1966)' (#652) from fix/1966-registry-test-watch into main 2026-11-10 14:40:51 +00:00
jschoubben 4a3914ee1c S4: the fetch proves with a shut-off package load that it downloaded all a step loads (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@7cc81512a389
mesh/check checking 4a3914ee1ce7 on 3c0b4d29a8ea
go mod download loads only the module graph a go.mod names; a vet, a test or a build then loads
packages shut off from the network and failed far from the fetch, in fewer words (the builder's
"pull_drop.go:11:2: module lookup disabled by GOPROXY=off"). After each directory's download the
fetch loads its packages with their tests shut off and fails naming each package whose load
would reach the network. The graph of a sibling laid beside is loaded once with the network
first, so a go.mod the composer's build reads and the graph's download skipped is fetched, then
proven the same way.

1966/AC178-2: a row of TestAVerifyThatFailsEndsTheStepNamingTheDirectoryAndTheLine; the beside
case in TestTheFetchDownloadsTheGraphOfASiblingModuleLaidWithTheTreesItRequires.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 13:10:44 +01:00
jschoubben ce3284fc8f S3: the fetch takes a module's zero-version requirements from its module graph (issue 1966)
ADR 0573 §2 builds every module against the latest published version of each mesh package it
uses, whatever its go.mod names. The fetch took versions only for packages the directory's own
go.mod names, so a package only a module it replaces by a directory requires (mesh-build-image
replacing buildstep, which imports the node-build-go seat package) was never taken: go mod
download passed and every shut-off step looked it up on the network. Now the requirements of
each module the directory replaces by a directory count too, taken the same way (the ask, its
repository's asked head, else the latest) and said as named by that directory.

1966/AC178-1: rows of TestARequirementAtTheZeroVersionIsTakenFromTheAskOrTheTrunk.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 13:08:00 +01:00
jschoubben ad6e813fb0 S2: mesh-build-image and mesh-build-node name nodebuildgo, which buildstep imports (issue 1966)
Since buildstep's drop verb imports the node-build-go seat package, both modules' shut-off vet,
test and build looked it up on the network: their go.mod did not name it, so the fetch took no
version for it. go mod tidy adds it as an indirect requirement.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 13:04:39 +01:00
jschoubben b373e34b29 Fill gitea's pool keys in the provisioner client's gitea tests (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@3c0b4d29a8ea
mesh/check checking b373e34b29ec on 3c0b4d29a8ea
The template gained three config keys the tests' bus held no values for, so the client rightly never wrote server.env.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 12:51:37 +01:00
jschoubben 067b9a47da Play the config watch in the package seats' whole test, so the held callers list is proven (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@462db1c25719
mesh/check checking 067b9a47dafd on 462db1c25719
The holder holds its callers by a config watch since the callers were moved off per-call reads; the test's player answered only provision.get.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 11:00:57 +01:00
mesh-tools bfa4a0851a Merge pull request 'Review parts are asked one at a time: the node judges one at a time (issue 1966, quick fix)' (#651) from fix/1966-kb-one-part into main 2026-11-10 07:33:48 +00:00
jschoubben b7f78d47c5 Review parts are asked one at a time: the node judges one at a time, a second ask only spent its bound waiting (issue 1966, quick fix)
mesh/check checking b7f78d47c5c4 on bfa4a0851acf
mesh/delivery merged: source.main moved to novox/mesh-catalog@bfa4a0851acf
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-10 03:00:58 +01:00
mesh-tools c4463ba47f Merge pull request 'The condition keeper reads the node-engine's findings; claude-code's seat-list again answers the operator's sessions (issue 1966; group fix/1966-seat-list-unread)' (#650) from fix/1966-seat-list-unread into main 2026-11-10 00:44:30 +00:00
jschoubben c39503e322 S7: claude-code's seat-list again answers a proved operator's session
mesh/delivery merged: source.main moved to novox/mesh-catalog@c4463ba47fcf
mesh/delivery-group group fix/1966-seat-list-unread merged: every member is merged
mesh/check checking c39503e3227c on c4463ba47fcf | novox/mesh-catalog#650 in group fix/1966-seat-list-unread
claude_code_seat_list_again answered person.<name> and the healers, but every
call through the mesh MCP server is proved as its machine's tool runner
(issue 627); node-tools now stands for that session (issue 1966).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 00:59:07 +01:00
jschoubben 6c61d9facf S6: the condition keeper reads the node-engine's findings
ace's node-engine wrote seat-list-unread in its state findings, and no
condition rose: the keeper's observers did not name the node-engine. It now
reads mesh-host.findings, so ADR 0636 §1's condition is raised and clears at
the first full read (issue 1966).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 00:49:55 +01:00
mesh-tools a39b72c377 Merge pull request 'The build modules end a transport fault's run again, serve drop, and are held to the transport-fault classes (issue 1748; group fix/1748-transport-retry, merge last)' (#649) from fix/1748-transport-retry into main 2026-11-09 22:58:43 +00:00
jschoubben e712cd227f C5: the integration row reads the dropped step as dropped (ADR 0664 §4, issue 1748 review 1)
mesh/delivery-group group fix/1748-transport-retry merged: every member is merged
mesh/delivery merged: source.main moved to novox/mesh-catalog@a39b72c377a5
mesh/check checking e712cd227f37 on a39b72c377a5 | novox/mesh-catalog#649 in group fix/1748-transport-retry
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:44:08 +01:00
jschoubben 12059ad3cc F5: the failurekit check refuses a build module whose manifest does not ask seat-settle-delay (ADR 0664 §5, issue 1748 review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:42:46 +01:00
jschoubben 6a29e41175 F3: mesh-build-go and mesh-build-node follow the drops other holders write, and a module with no watch says so (ADR 0664 §4, issue 1748 review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:42:13 +01:00
jschoubben 1948da9cb4 F8, dropped: the build module's drop uses the generated contracts' Drop and ends the step dropped, with the named cause (ADR 0664 §4, issue 1748 review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:41:10 +01:00
jschoubben b84a2f4dc1 F6b: merge main (#647) into the transport retry: unknownRevision reads the transport words, the row says passing (issue 1748 review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:33:33 +01:00
mesh-tools 9c42423d58 Merge pull request 'The knowledge base's review knows its round: from round 3 only a wrong record asks changes, at round 5 the operator decides (issue 784)' (#648) from fix/784-review-round into main 2026-11-09 22:26:59 +00:00
jschoubben c12f1a16db get shows a round only for a proposed version (issue 784, ADR 0665)
mesh/delivery merged: source.main moved to novox/mesh-catalog@9c42423d5821
mesh/check checking c12f1a16db83 on 9c42423d5821
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-09 23:22:46 +01:00
jschoubben 09ea4a40bf A closing keeps the review round, get says the round, a close waits for the operator's round-5 answer (issue 784, ADR 0665)
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-09 23:20:22 +01:00
jschoubben 6b8e7fc621 C1c, C6: any holder serves drop and every node follows it by a watch of the shared pauses; the build modules serve drop and ask the tool runner's seat-settle-delay (ADR 0664 §1, §4, §5, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:19:28 +01:00
jschoubben 3c3fdd3446 C5: an integration test of the delayed redelivery and the drop of a running step against the real bus (ADR 0664 §1, §5, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:16:09 +01:00
jschoubben 06f28fbadb C4: the conformance kit gains Passing with the words of each runner when the network did not answer; each build module's tests call it and checks/failurekit refuses one that does not (ADR 0664, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:15:35 +01:00
jschoubben 62644aa012 The knowledge base's review knows its round: from round 3 only a wrong record asks changes, at round 5 the operator decides (issue 784, ADR 0665)
Claude-Session: https://claude.ai/code/session_01JG73KK8HD1gufnqyFxMFdW
2026-11-09 23:13:55 +01:00
jschoubben bdfd8db265 C3: mesh-build-node and mesh-build-image say a failure of the network as a transport fault (npm ci, buildah bud, the image seat's mirror) (ADR 0664 §1, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:13:45 +01:00
jschoubben 89ec9429db C2b: a seat verb's no responders is a transport fault when the assigner names a holder of the seat; a seat nothing holds fails at once (ADR 0664 §1, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:12:35 +01:00
jschoubben b9cfa4fe16 C2: mesh-build-go says a failure of the network as a transport fault: one download, no retry of its own (ADR 0664 §1, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:10:16 +01:00
mesh-tools 8b184d8322 Merge pull request 'mesh-build-go reaches the forge's git when the public proxy is unreachable, and a network failure is no pin (issue 1966)' (#647) from fix/1966-contracts-goproxy into main 2026-11-09 22:08:33 +00:00
jschoubben 02d57c4a53 C1b, C1c: the seat's verb drop refuses a dropped ask at pull, ends one that runs, and the start sweeps old drops (ADR 0664 §5, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:07:49 +01:00
jschoubben 5a7561fe1d C1: a build step that ends on a transport fault below its last run is written again and handed back to the bus with a delay (ADR 0664 §1, issue 1748)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 23:04:44 +01:00
mesh-tools 5e537f851f Merge pull request 'Distribution and the forge hold their caller list from its config watch, not an ask per call (issue 1966; group with mesh-sdk)' (#646) from fix/1966-callers-config-read into main 2026-11-09 22:00:48 +00:00
mesh-tools 0a6a5aba68 Merge pull request 'mesh-build-image names its copy wait's ownership mark nonce, so the state takes it and nats images are stored again (issue 1966)' (#645) from fix/1966-mirror-waits-token into main 2026-11-09 22:00:32 +00:00
jschoubben 407fc6c9e6 The Go seat goes on to the public proxy only on a 404, the public proxy to direct on any failure (issue 1966, review 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@8b184d83222c
mesh/check checking 407fc6c9e625 on 9c42423d5821
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 22:55:53 +01:00
jschoubben 6299b47f4f A mesh package the Go seat lacks is fetched from the forge's git, not stopped by an unreachable public proxy (issue 1966)
GOPROXY joined the Go seat, the public proxy and direct with commas, so go went on only after a 404 or 410: a contracts
package the seat does not hold, and a public proxy a node cannot reach, ended the fetch before direct. It joins them
with | now, so any failure goes on to the next. go words a proxy it cannot reach as "invalid version", which the pin
path read as an unknown revision and labelled "on no pull request head"; a network failure is never that now
(network is unreachable and no route to host are network failures).

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 22:42:09 +01:00
jschoubben 24bf59219e Distribution and the forge hold their caller list from its config watch, not an ask per call (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@5e537f851f08
mesh/delivery-group group fix/1966-callers-config-read merged: every member is merged
mesh/check checking 24bf59219ee5 on 9c42423d5821 | novox/mesh-catalog#646 in group fix/1966-callers-config-read
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 22:42:00 +01:00
jschoubben e3b4ecde8c Name the copy wait's ownership mark nonce, not token, so the state accepts it; the copy test's state refuses credential names as the bus does (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@0a6a5aba682e
mesh/check checking e3b4ecde8cf2 on 5e537f851f08
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 22:42:00 +01:00
mesh-tools fd9147087e Merge pull request 'The bus module reads a consumer's counts and a stream's events, so a lost event can be traced' (#644) from fix/1966-nats-consumer-read into main 2026-11-09 21:27:30 +00:00
jschoubben a3d141e793 nats_stream_messages shows a body only for the stream EVENTS (issue 1966, review 1)
mesh/check checking a3d141e79351 on fd9147087ef6
mesh/delivery merged: source.main moved to novox/mesh-catalog@fd9147087ef6
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:21:03 +01:00
jschoubben 4fbe6e347f nats_stream_messages shows a body only on an event stream (issue 1966, review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:20:19 +01:00
jschoubben 3d70bd3660 nats_stream_messages refuses a module's kept state (KV_*, OBJ_*), which can hold secrets (issue 1966, review 1)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:18:43 +01:00
jschoubben a4e5a8ffef Format the bus tools' new test (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:14:00 +01:00
jschoubben 13dea6b1aa The bus module reads one consumer in full and a stream's messages by subject (issue 1966)
A lost event between a shared stream and a module's consumer could not be located: no tool read a consumer's delivered and acknowledged sequence or a stream's messages for one subject. nats_consumer reads from the monitoring document; nats_stream_messages asks STREAM.MSG.GET on the module's own credential, newest first and bounded.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:13:02 +01:00
mesh-tools 6d2ce96630 Merge pull request 'The resolver module's tests hold its configuration's guards' (#643) from fix/1966-resolver-test into main 2026-11-09 20:09:34 +00:00
jschoubben a7dc4534d7 Hold the resolver's configuration guards in the dnsmasq module's tests (issue 1966)
mesh/check checking a7dc4534d73a on 6d2ce96630f0
mesh/delivery merged: source.main moved to novox/mesh-catalog@6d2ce96630f0
mesh/delivery-group group fix/1966-resolver-test merged: every member is merged
Moved from mesh-sdk's catalogue tests, which read a literal the file no longer has.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 20:59:46 +01:00
mesh-tools 4387e77f28 Merge pull request 'mesh-hand-acts answers bounded, newest first; mesh-healers seeds from a partial answer (issue 1966 S8, recovery)' (#642) from fix/1966-s8-hand-acts-window into main 2026-11-09 17:55:10 +00:00
jschoubben 443a340f06 Listing.Complete comment says what the field means now (issue 1966 S8 review 2)
mesh/check checking 443a340f06cc on 4387e77f2899
mesh/delivery merged: source.main moved to novox/mesh-catalog@4387e77f2899
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-09 18:23:33 +01:00
jschoubben 15f81780b9 hand-acts: no entry skipped by an early-ended page, the answer counted as sent, completeness for the window asked (issue 1966 S8 review 1)
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-09 18:23:33 +01:00
jschoubben ea4dfaa5a2 mesh-hand-acts answers bounded, newest first; mesh-healers seeds from a partial answer (issue 1966 S8)
hand-acts read the whole log (84k entries, 47 MB) on every call and could never answer inside the call bound or
the bus payload. It now reads from the newest entry backwards, page by page, and stops at the window's start, at
900 KiB of acts or at 20 s, saying complete: false, cut and why. warrants is bounded the same way. mesh-healers'
start-up seed takes a partial answer and logs it, instead of retrying forever.

Quick fix: this (mesh-catalog only, about 1.5 h). Thorough fix: also the tool runner never freezing on an oversized
bundle line and logging unanswered calls (mesh-tools, a separate fix by another developer, lead's ruling Q3), and an
SDK-side bound. Chosen here: quick for this module, the thorough part running beside it; the byte cap is the
module's own (lead's ruling Q1, a `limit` argument is a later note).

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-09 18:23:33 +01:00
mesh-tools 7630e3cc9d Merge pull request 'Sessions merge without a human approval: the hard rule keeps a branch and a pull request for every change' (#641) from docs/no-merge-approval into main 2026-11-09 16:58:54 +00:00
jschoubben 994b25f540 Sessions merge without a human approval: the hard rule keeps a branch and a pull request for every change
mesh/check checking 994b25f540a1 on 7630e3cc9d1e
mesh/delivery merged: source.main moved to novox/mesh-catalog@7630e3cc9d1e
The operator, 2026-11-09: "Remove the instruction, i don't care about merges, and little about deliveries".

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 17:57:51 +01:00
mesh-tools 503411e628 Merge pull request 'mesh-build-go reads a compose ask's gzip-packed request (issue 1966, recovery; group with mesh-delivery)' (#640) from fix/1966-checker-compositions-objects into main 2026-11-09 16:31:22 +00:00
jschoubben 4e2a1a5a1f mesh-build-go reads a compose ask's gzip-packed request (issue 1966)
mesh/check checking 4e2a1a5a1f58 on 503411e6289b | novox/mesh-catalog#640 in group fix/1966-checker-compositions-objects
mesh/delivery merged: source.main moved to novox/mesh-catalog@503411e6289b
mesh/delivery-group group fix/1966-checker-compositions-objects merged: every member is merged
The checker sends a large node's request packed, as it passes the bus's message limit; compose reads either form through
ComposeJob.RequestBytes. Only mesh-build-go serves compose. Delivered as a group with the mesh-delivery change of the same
branch name: an older mesh-build-go refuses a packed ask naming that it holds no request object.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-09 13:36:38 +01:00
mesh-tools 6a24e4d0c7 Merge pull request 'Gate record of mesh-catalog's forge-made merges (issue 1966, recovery)' (#639) from fix/1966-forge-made-merge into main 2026-11-09 08:45:50 +00:00
jschoubben 80186d0798 Record this repository's forge-made merges in the delivery gate, so the indexer takes them in (issue 1966, recovery)
mesh/delivery merged: source.main moved to novox/mesh-catalog@6a24e4d0c77e
mesh/delivery-group group fix/1966-forge-made-merge merged: every member is merged
mesh/check checking 80186d079808 on 6a24e4d0c77e | novox/mesh-catalog#639 in group fix/1966-forge-made-merge
Merges made on the forge directly since 2026-11-09 07:04 UTC have no gate merge record,
and the indexer holds them; an empty merge through the gate covers this repository's head.
Second of its kind: the group's mesh-delivery member removes the need (the forge bridge
reads a forge-made merge's pull request from the forge).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 09:17:03 +01:00
mesh-tools 89cd8a2e8e Merge pull request 'The provisioner keeps a provision while a module's newest composition is unread, and withdraws an ask once a key is needed again (issue 1966, recovery)' (#638) from fix/1966-provisioner-newest-held into main 2026-11-09 08:07:53 +00:00
jschoubben 4658e1cb1c A newest hold's key is <node>.<module>=newest, which the bus holds, and the test store refuses what the bus refuses (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@89cd8a2e8ed2
mesh/check checking 4658e1cb1c6c on 6a24e4d0c77e
Review round 2: '@' is outside the bus's key rule, so every newest hold failed to write
on the live bus while the in-memory store took it.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 09:06:39 +01:00
jschoubben 6e6b325cf1 Newest holds are kept on the bus in refused, and every key needed again withdraws its retire ask by one helper (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 09:02:40 +01:00
jschoubben bbe107fa2a A provision only the newest build requires is kept while that build's composition is unread, and a key needed again withdraws its retire ask (issue 1966)
Not read is not unneeded on the newest scope too (issue 940's rule): a module whose newest composition is
unpublished or refused is held at its newest build, said in provisioner_plans newest-held and bounded on the health.
A retiring key needed again cancels its ask on operator-channel (accept.cancel), retried until the router takes it.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 08:52:16 +01:00
mesh-tools ac94f8338f Merge pull request 'distribution's program listens on 5100 and the registry container on 5001, so no mapping names the artifact store's port (issue 1966, recovery)' (#637) from fix/1966-store-listen into main 2026-11-09 00:58:10 +00:00
jschoubben a0459a8553 distribution's manifest test reads the listens with its own struct and checks every mapping's sides for 5100
mesh/delivery merged: source.main moved to novox/mesh-catalog@ac94f8338f9c
mesh/check checking a0459a855311 on ac94f8338f9c
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 01:42:40 +01:00
jschoubben 6cf7f67a1d distribution's program listens on 5100 and the registry container on 5001, so no mapping names the store's port
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 6cf7f67a1dfd on 08fbc51af26f
The composer finds a listen's machine port in a container mapping that names
it on either side (MachineSide). The program's listen 5000 matched the
registry container's inner 5000 in 127.0.0.1:5001:5000, so the store's address
became the container's loopback side and the program's own port was never
opened: the move to 83cd1a3d on 2026-11-08 23:25 UTC cut the artifact store off
(issue 1966 note 109). The program now binds 5100, where every machine fetches
(the artifact-store alias and the seats' reach agree), and the container serves
5001 inside and out. A row holds that no container names 5100.

Undo: revert this commit.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 01:40:49 +01:00
mesh-tools 08fbc51af2 Merge pull request 'The keeper's health reads the consumers it looks for (issue 1966, recovery)' (#636) from fix/keeper-health-consumers into main 2026-11-08 22:55:44 +00:00
jschoubben 1ef1b13560 The keeper's health reads the consumers it looks for: the shared monitoring read asks for them
mesh/delivery merged: source.main moved to novox/mesh-catalog@08fbc51af26f
mesh/check checking 1ef1b1356044 on 08fbc51af26f
nats_keeper_health found the keeper's consumer novox_nats_keeper in /jsz, but the read it shares with the
broker verbs asked /jsz?accounts=1&streams=1&config=1, without consumers=1, so keeperPending always read -1
and the keeper was "unhealthy: what waits on its consumer cannot be read from the bus's monitoring" for
ever (2026-11-08, issue 1966).

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 23:55:32 +01:00
mesh-tools 7fd4ed5898 Merge pull request 'The bus keeper reads a bare stream's max-age-days "none" as kept for good (issue 1966, recovery)' (#635) from fix/keeper-age-none into main 2026-11-08 22:53:04 +00:00
jschoubben bc8cb3b153 The bus keeper reads a bare stream's max-age-days "none" as kept for good, as the judge accepts it
mesh/delivery merged: source.main moved to novox/mesh-catalog@7fd4ed589806
mesh/check checking bc8cb3b153cf on 7fd4ed589806
mesh-knowledge's stream reviews says max-age-days "none", which the indexer's judge accepts
(judge/busstate.go noAge), but the keeper refused it ("not a whole number"), so the stream was
never made and the keeper stayed unhealthy (2026-11-08, issue 1966). Quick fix; the thorough one,
one shared reader of the template options in mesh-sdk catalogue for the judge and the keeper, is
noted on issue 1966.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 23:52:51 +01:00
mesh-tools 9480557537 Merge pull request 'dnsmasq: the public upstream resolvers are settings, not fixed in the template' (#634) from fix/dnsmasq-upstreams into main 2026-11-08 22:51:12 +00:00
jschoubben c88be88ccf dnsmasq's public upstream resolvers are config values, defaulting to the two it always used
mesh/delivery merged: source.main moved to novox/mesh-catalog@94805575379f
mesh/check checking c88be88ccf2f on 94805575379f
The two server lines were literals in the template, so changing an upstream needed a build. They are now the config keys upstream-resolver-first (1.1.1.1) and upstream-resolver-second (8.8.8.8), placed with ${config:...}; the service already restarts on its config file. no-resolv and every other line stay.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 23:48:59 +01:00
mesh-tools 53d173aa7d Merge pull request 'Forge database pool caps are config values, and a changed value recreates the forge (issue 1966, recovery)' (#633) from fix/1966-gitea-pool-config into main 2026-11-08 03:41:23 +00:00
jschoubben 4becc7bd83 Forge database pool caps are config values and a change recreates the forge (issue 1966, recovery)
mesh/delivery merged: source.main moved to novox/mesh-catalog@53d173aa7de5
mesh/check checking 4becc7bd8312 on 53d173aa7de5
The caps were literals in server.env and the container named no restart-on, so a changed env-file never reached the running forge. They are now three gitea config values placed with ${config:...}, and the server container restarts on server-env.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 03:33:16 +01:00
mesh-tools 61737962fc Merge pull request 'Cap the forge's database pool at 40: the store's 100 connections are shared (issue 1966, recovery)' (#632) from fix/1966-forge-pool-cap into main 2026-11-08 01:08:36 +00:00
jschoubben b4192c525e Cap the forge's database pool at 40 open connections: the store's 100 are shared by every module (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@61737962fcb6
mesh/check failed (3): ace: the check of novox/mesh-catalog#632 failed: downloading the asked version 68581…; and 2 more | b4192c525e7e on 61737962fcb6
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 02:01:20 +01:00
mesh-tools d03b455ea6 Merge pull request 'Manifests name their packages by full module path (issue 1966, bootstrap recovery)' (#631) from fix/1966-packages-by-module-path into main 2026-11-08 00:01:29 +00:00
jschoubben 1171b684d0 Round 1: distribution's manifest test expects the artifact-store alias the bootstrap keeps
mesh/delivery merged: source.main moved to novox/mesh-catalog@d03b455ea6e4
mesh/check checking 1171b684d08b on d03b455ea6e4
The review of 6547933b: the test still wanted the provision retired, though
80b8002a and 0c465fb9 keep it on port 5100 for the running node-engines; it
failed on main too (issue 1966).

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 01:00:40 +01:00
jschoubben 6547933b4d Name packages by their module path in 15 manifests (issue 1966)
Kind: short package names (builds, sshkeys, forgemsg, backupgood,
diskreadings, grantsstate, busgrants) in `packages`, which the indexer's
judge refuses by design 32's rule (a Go package is named by its go.mod's
module path). This held mesh-delivery-gate, the build modules, mounts and
restic. Each key is now the import path the module's code uses, with its
series kept; the manifest tests read the new keys, and the host/machine-name
word tests in mounts, nfs-server, mesh-build-node and mesh-delivery-gate
look past the path's `git.novox.be/novox/` prefix.

Quick fix: only the gate's manifest. Thorough fix (chosen): all 15, since
the same refusal holds each. Follow-up for the checker: the candidate
verdict lets such a name through (J7(3)), so the merge check did not stop it.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 00:38:55 +01:00
mesh-tools 0cd706b044 Merge pull request 'The gate part reads no pull request description: no review-line wait before the Approve (ADR 0659)' (#630) from fix/gate-reads-no-description into main 2026-11-07 22:55:00 +00:00
jschoubben 908f3918de A test over the gate's waits: no wait reads a description (ADR 0659 clause 6)
mesh/delivery merged: source.main moved to novox/mesh-catalog@0cd706b044ae
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-07 23:36:49 +01:00
jschoubben 8b84ac7487 Cite ADR 0659 where the gate reads no description; check-gates-merge words no longer wait for a review
Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-07 23:33:05 +01:00
jschoubben 9698bcd934 The merge gate reads no pull request description: the review hold goes (reviewWaits, reviewWords, the Reviewed-by line)
The operator, 2026-11-07: remove any gate that reads a description. An unreviewed pull request whose
other waits are met is asked, and the ask names no review line.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-07 23:10:43 +01:00
jschoubben 21aa11aef1 Merge pull request 'The gate asks the Approve at once while the check gates no merge, never waiting for a verdict (issue 1966)' (#629) from fix/1966-no-check-wait into main
Reviewed-on: #629
2026-11-07 20:47:18 +00:00
jschoubben 145cf7e33a The gate asks the Approve at once while the check gates no merge, never waiting for a verdict (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@21aa11aef1af
mesh/check checking 145cf7e33afa on 21aa11aef1af
With check-gates-merge false (the configuration seat's value) the gate still held the Approve until the check had
a verdict (verdictWaits), so a one-line recovery waited half an hour for a whole-suite check it does not gate. The
operator, 2026-11-07: 'we aren't running any checks anymore, we're delivering'; 'disable the check'. The Approve is
now asked once the delivery is reviewed and ready; a later verdict is still shown and asks the pending Approve again
in its words. Tests follow: asked at once with no verdict or a running check, a group too, a re-pushed member too.

Merged by the session as a recovery fix (instruction recovery-fixes-merge-and-tell), the operator told on Telegram.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 21:46:34 +01:00
jschoubben 9a3356bd38 Merge pull request 'Take the Go package artifacts out of the delivery parts for now, so the running builder builds them (issue 1966)' (#627) from fix/1966-bootstrap-no-packages into main
Reviewed-on: #627
2026-11-07 18:50:37 +00:00
jschoubben 83732dcd53 Take the Go package artifacts out of the delivery parts for now, so the running builder builds them (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@9a3356bd38b1
mesh/delivery-group group fix/1966-bootstrap-no-packages merged: every member is merged
mesh/check checking 83732dcd53d8 on 9a3356bd38b1 | novox/mesh-catalog#627 in group fix/1966-bootstrap-no-packages
The running builder predates Go package artifacts ("refused kind: package of language go ... no build module
builds it"), so it can build neither the new builder, the grants module, the Go build module nor the checker:
each new part is needed before the others can be built or delivered. For the bootstrap of the registry
delivery, at the operator's word (19:4x, "do the temporary change"), the package artifacts leave the manifests of
the parts that run; the contracts and SDK modules, which run on no node, keep theirs. They come back in one change
once the new builder runs. Quick vs thorough: quick; the thorough fix is 47/G's tier order (issue 1921 note 80).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 19:48:57 +01:00
jschoubben adcddb40cc Merge pull request 'distribution's artifact-store alias names port 5100, where the store answers (issue 1966)' (#626) from fix/1966-store-port into main
Reviewed-on: #626
2026-11-07 18:21:56 +00:00
jschoubben 0c465fb949 distribution's artifact-store alias names port 5100, where the store answers (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@adcddb40cc30
mesh/check checking 0c465fb9495f on adcddb40cc30
mesh-catalog #625 offered artifact-store again with serves port 5000, copied from the manifest before the
registry milestone. The running node-engines turn that port through the ports the node assigned the module; with
the milestone's manifest the assignment no longer turns 5000 into 5100, where the store's container has published
since 2026-10-04 (docker_ports on novox: 0.0.0.0:5100->5000). Since #625 was taken in, novox fetches from :5000
and is refused (node-engine journal 18:54: connection refused), while the store's log shows fetches on :5100
until 18:47:52. The alias now names 5100. Removed with the alias once every node-engine runs the milestone's build.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 19:00:07 +01:00
mesh-tools bac7f503f0 Merge pull request 'distribution provides artifact-store again beside its new roles, for the running node-engines (issue 1966)' (#625) from fix/1966-store-old-name into main 2026-11-07 17:46:40 +00:00
jschoubben 80b8002a94 distribution provides artifact-store again beside its new roles, for the running node-engines (issue 1966)
mesh/delivery merged: source.main moved to novox/mesh-catalog@bac7f503f0ae
mesh/check checking 80b8002a94f7 on bac7f503f0ae
The registry milestone renamed what distribution offers (container-image-registry, oci-artifact-registry,
mesh-artifact-store), as ADR 0632 section 3 decides. The node-engines running on every node are the ones
from before it: they find the store through the provision artifact-store, so they fetch no new build,
including their own successor, and keep running what they ran. ADR 0632 section 2's guarantee (no node loses
the store while its engine reads it the old way) lives in the new indexer's hold, which the running indexer
predates. So this goes against ADR 0632 section 3 for a limited time, at the operator's word (bootstrap of
2026-11-07, no new record): offered again beside the new roles until every node-engine runs the registry
milestone's build, then removed in the next change to distribution.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 17:02:47 +01:00
mesh-tools 1c1b03c3a0 Merge pull request 'Registry milestone: mesh-catalog (issue 1966)' (#612) from feat/registry-milestone into main 2026-11-07 14:18:53 +00:00
jschoubben 67d9ccb1d4 Merge origin/main: the trunk's fetch downloads each placed sibling's module graph (mesh-catalog #624) over the group's earlier version (issue 1921, milestone 47/G)
mesh/delivery merged: source.main moved to novox/mesh-catalog@1c1b03c3a02b
mesh/delivery-group group feat/registry-milestone merged: every member is merged
mesh/check checking 67d9ccb1d455 on 1c1b03c3a02b | novox/mesh-catalog#612 in group feat/registry-milestone
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 12:34:01 +01:00
mesh-tools 0f52013878 Merge pull request 'mesh-build-go's fetch downloads the module graph of each placed sibling Go module (issue 1921)' (#624) from fix/1921-fetch-downloads-beside into main 2026-11-07 11:09:36 +00:00
jschoubben 36c19694c9 The fetch walks the siblings for Go modules only when one is a Go module at its root; the beside workspace's go directive is the highest among its modules (issue 1921)
mesh/delivery merged: source.main moved to novox/mesh-catalog@0f5201387891
mesh/check checking 36c19694c9df on 0f5201387891
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 11:49:42 +01:00
jschoubben 77b30bd296 The check's fetch downloads the graph of a sibling Go module laid with the trees it requires (issue 1921, milestone 47/G trunk step)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: shanks: the check of novox/mesh-catalog#624 failed: downloading the asked version 54aff0a3d9201e3df2… | 77b30bd2966c on f2b332de73d6
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 11:36:08 +01:00
jschoubben 81287cb11d The check's fetch downloads the graph of a sibling Go module laid with the trees it requires; the compose test fetches nothing (issue 1921, milestone 47/G, the group check of c5d32074c945)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 08:32:14 +01:00
jschoubben 962e8547b1 The lab is marked moved: its forge files are templates filled from the forge-account binding, which the controller cannot answer (issue 1966) 2026-11-07 08:15:37 +01:00
jschoubben ea25e4d589 The compose test downloads the composer's modules as the fetch step does, so no cache of this machine decides (issue 1921, milestone 47/G, the group check of c5d32074c945)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 08:14:15 +01:00
jschoubben 422b22ab0e The composer's test workspace prefers the catalogue under check over its trunk laid beside (issue 1921)
mesh/delivery-group group feat/registry-milestone checking: 7 of 7 member(s) ready
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 422b22ab0e1a on 0f5201387891 | novox/mesh-catalog#612 in group feat/registry-milestone
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 06:13:55 +01:00
jschoubben 91edd0bfdb The composer's test workspace lays what the composer requires, found in the group's trees and the catalogue's own (issue 1921)
The group check of 8a80e8e86858 laid every Go module beside the check, and one of them (mesh-delivery's
mesh-grants, which now requires disk-load's diskreadings) required a catalogue module the catalogue under check
never lays beside itself: Go looked its zero version up and the composer did not build.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 06:00:52 +01:00
jschoubben 81874e4748 Ignore the requiredmodules check's program, as the gitignored check asks of every main package (issue 1921)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 05:56:47 +01:00
jschoubben 62fe1cc36d mesh-build-go requires the checker client it imports; a check refuses any module importing a mesh module its go.mod does not require (issue 1921, issue 1966)
mesh/delivery-group group feat/registry-milestone rejected: composed together: the checker's verdict at the members' heads (8a80e8e86858): unit: git.novox.be/n…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (4): unit: git.novox.be/novox/m…; and 3 more | 62fe1cc36d30 on f2b332de73d6 | judged by the candidate's mesh-build-go at e8757a23634d
Quick fix: add the one require to mesh-build-go's go.mod (a minute). Thorough fix, chosen because the kind was met twice (a
local go.work resolving what go.mod never names) and hides itself from every local build: checks/requiredmodules reads every
go.mod and Go source (tests and tagged files included), finds the owning module by the longest module line among the
repositories beside, and fails in go test and the repository check before any push. The scan over the seven group heads
found this one instance.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 04:23:47 +01:00
jschoubben 7bf15a3dc4 The unread-ask test and the ReadManifest note follow the delivery domain SDK's names and the ask to the indexer (issue 1921, issue 1966)
mesh/delivery-group group feat/registry-milestone rejected: composed together: the checker's verdict at the members' heads (616c42434ac2): ace
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (3): ace: the check of novox/mesh-catalog#612 failed: the unit level: the candidate's mes…; and 2 more | 7bf15a3dc4db on f2b332de73d6
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 03:15:18 +01:00
jschoubben ba17347518 mesh-build-go runs a program on the contract's run ask: the delivery domain SDK's job, key and bound, and the checker client's input ask (issue 1921, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 02:41:39 +01:00
jschoubben 8c67c224c2 Merge the trunk (B0) into the registry group: one invokes list in mesh-build-go's manifest, the manifest asked of its owner and the checker's input asked of the seat (issue 1921, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 02:18:55 +01:00
mesh-tools f2b332de73 Merge pull request 'mesh-build-go runs a checking program over a change's heads (issue 1921, B0)' (#622) from feat/b0-checking-program into main 2026-11-07 00:32:47 +00:00
mesh-tools 980ade9338 Merge pull request 'Progress reads only a design's head, so a long design never takes the progress view down (issue 1947)' (#623) from fix/1947-progress-bounded-read into main 2026-11-06 23:41:28 +00:00
jschoubben 7c0ce2fe21 Progress: an unread design is not cached and is warned of at every level; head is capped (issue 1947)
mesh/delivery merged: source.main moved to novox/mesh-catalog@980ade933853
mesh/check being carried to 7c0ce2fe218b on 980ade933853 | from 1da83c29ddf9
2026-11-07 00:25:02 +01:00
jschoubben be7d649d1f Progress reads only a design's head, and says so beside the tracker's tree when even that fails (issue 1947) 2026-11-07 00:21:17 +01:00
jschoubben c8bcb2cdf5 mesh-build-go asks a run's input of the seat verb the ask names, and reads no checker state (issue 1921, B0 review round 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@f2b332de73d6
mesh/delivery-group group feat/b0-checking-program merged: every member is merged
mesh/check being carried to c8bcb2cdf567 on f2b332de73d6 | from 980ade933853, hq 885099eebbbd, mesh-delivery 2d201f781ced, mesh-sdk c1b50e99a639
The operator's answer: reading mesh-delivery-checker.inputs broke ADR 0596 §2. The manifest drops that read and invokes
seat:mesh-delivery-checker.input; the run step asks the verb through the runtime (stdio.Ask) and takes only the bytes
the ask names (delivery.RunInput.Read).

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 23:43:05 +01:00
jschoubben 608c4d4be6 Fail a run step whose answer is over the bus's value limit, naming its size (issue 1921, review round 1)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 23:26:12 +01:00
jschoubben da1356cd7c Bind a run's heads read-only over its workspace, read its input from the state the ask names, and prove the kept programs' sweep (issue 1921, review round 1)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 23:24:48 +01:00
jschoubben fa73674e22 A run ask builds a checking program once and runs it over its heads and input; compose keeps seats (issue 1921, C1-C3)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:20:25 +01:00
mesh-tools 1da83c29dd Merge pull request 'operator-channel.asks answers open asks within a bound when asked with no argument (issue 1974)' (#621) from fix/1974-asks-default-bounded into main 2026-11-06 21:05:59 +00:00
jschoubben 428ef8bbe5 Kept composers and programs get a list tool, a drop tool and a sweep (issue 1921, seams must 2)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:05:05 +01:00
jschoubben 0220dfa92b Answer an ask the build module cannot read on its step key as refused (issue 1921, seams must 1)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:03:51 +01:00
jschoubben b6d85847f8 operator-channel.asks answers the open asks within a default bound, and says what it cut (issue 1974)
mesh/delivery merged: source.main moved to novox/mesh-catalog@1da83c29ddf9
mesh/check being carried to b6d85847f855 on 1da83c29ddf9 | from 1d07778c88dc
Why: with no argument the verb answered every ask of the month, 737,934 characters, more than a session reads.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 21:51:44 +01:00
mesh-tools 1d07778c88 Merge pull request 'knowledge-base get shows the findings of the pending version's reviews (issue 1970)' (#620) from fix/1970-pending-review-findings into main 2026-11-06 15:24:37 +00:00
jschoubben ddb2ae9420 knowledge-base get shows the findings of the pending version's reviews (issue 1970)
mesh/delivery merged: source.main moved to novox/mesh-catalog@1d07778c88dc
mesh/check being carried to ddb2ae942030 on 1d07778c88dc | from 332d37311485
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 15:39:16 +01:00
mesh-tools 332d373114 Merge pull request 'mesh-build-go keeps a composer per the versions it was asked to build with (issue 1921)' (#619) from fix/1921-zero-version-builds into main 2026-11-06 08:00:22 +00:00
278 changed files with 12745 additions and 2318 deletions
+1
View File
@@ -20,6 +20,7 @@ dist/
/checks/predefined/predefined
/checks/readable/readable
/checks/removedpackages/removedpackages
/checks/requiredmodules/requiredmodules
/checks/secrets/secrets
/checks/sshdir/sshdir
/checks/templates/templates
+26 -2
View File
@@ -4,7 +4,8 @@
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
// exempt from that, and said so. Every build module, test step or not, must also hold a test that calls
// conformance.LeftRunning with its own start sweep, so a step a previous process left running is ended at its start
// (issue 1890).
// (issue 1890), and one that calls conformance.Passing, so a step whose network did not answer is said as a transport fault
// (issue 1748). Its manifest asks the capability seat-settle-delay (ADR 0664 §5).
package failurekit
import (
@@ -13,6 +14,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
)
@@ -27,9 +29,15 @@ type manifest struct {
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
Tools []json.RawMessage `json:"tools"`
Tools []json.RawMessage `json:"tools"`
Capabilities []string `json:"capabilities"`
}
// settleDelay is the capability a tool runner states when it can settle a seat's ask with a delayed negative acknowledgement
// (mesh-sdk CapSeatSettleDelay): a build module gives its ask again after a delay by it, so the delivery plan places a tool
// runner that states it before the module (ADR 0664 §1, §5).
const settleDelay = "seat-settle-delay"
// Judge reads every modules/<m>/module.json of the catalogue at root and judges each module that claims a node-build-*
// seat.
func Judge(root string) ([]Judged, error) {
@@ -83,6 +91,22 @@ func Judge(root string) ([]Judged, error) {
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
"(mesh-build-go buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
}
// Every build module meets the network (a fetch, a download, an install, a publish), so every one runs the passing case: its
// steps say a network that did not answer as a transport fault the bus gives the ask again for (ADR 0664, issue 1748).
says, err := runsTheKit(dir, "conformance.Passing(")
if err != nil {
return nil, err
}
if !says {
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.Passing "+
"(mesh-build-go buildstep/conformance): run the kit's passing case with the module's own path from a failed run to its step's error (issue 1748)", seat))
}
// Every build module gives a step that met a transport fault again by the bus (ADR 0664 §5): it asks the tool runner's
// capability in its manifest, so the delivery plan places a tool runner that states it first.
if !slices.Contains(m.Capabilities, settleDelay) {
refused = append(refused, fmt.Sprintf("it claims %s, and its manifest's capabilities do not ask %s: a step given again by the bus needs "+
"a tool runner that can settle with a delay (ADR 0664 §5, issue 1748)", seat, settleDelay))
}
j.Refused = strings.Join(refused, "; ")
out = append(out, j)
}
+11 -6
View File
@@ -8,23 +8,28 @@ import (
// 1901/AC7 and 1890/AC8: a build module with a test step and no kit is refused, one that runs the kit passes, one with no
// test step is exempt from the failure cases but not from the left-running case, a module that runs only the failure
// cases is refused, and a module that claims no node-build-* seat is not judged.
// cases is refused, a module that does not ask the capability seat-settle-delay is refused (ADR 0664 §5), and a module that
// claims no node-build-* seat is not judged.
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
root := t.TempDir()
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n"
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\nfunc TestPassing(t *testing.T) { conformance.Passing(t, nil, nil) }\n"
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
for path, body := range map[string]string{
"modules/a/module.json": `{"claims":[{"name":"node-build-a"}],"tools":["build_a_fetch","build_a_test"]}`,
"modules/a/cmd/a/check_test.go": "package main\n",
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"tools":[{"name":"build_b_test"}]}`,
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"capabilities":["seat-settle-delay"],"tools":[{"name":"build_b_test"}]}`,
"modules/b/cmd/b/kit_test.go": kit,
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"tools":["build_c_build"]}`,
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"capabilities":["seat-settle-delay"],"tools":["build_c_build"]}`,
"modules/c/cmd/c/kit_test.go": imp + sweep,
"modules/f/module.json": `{"claims":[{"name":"node-build-f"}],"tools":["build_f_build"]}`,
"modules/g/module.json": `{"claims":[{"name":"node-build-g"}],"tools":["build_g_test"]}`,
"modules/g/cmd/g/kit_test.go": imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n",
"modules/d/module.json": `{"claims":[{"name":"other"}],"tools":["d_test"]}`,
"modules/h/module.json": `{"claims":[{"name":"node-build-h"}],"tools":["build_h_build"]}`, // the sweep, and no passing case
"modules/h/cmd/h/kit_test.go": imp + "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n",
"modules/i/module.json": `{"claims":[{"name":"node-build-i"}],"capabilities":["other"],"tools":["build_i_build"]}`, // the whole kit, and no seat-settle-delay
"modules/i/cmd/i/kit_test.go": imp + sweep,
"modules/e/module.json": `{"claims":[{"name":"node-build-e"}],"tools":["build_e_test"]}`,
"modules/e/cmd/e/kit.go": kit, // not a test file
} {
@@ -40,9 +45,9 @@ func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused"}
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused", "h": "refused", "i": "refused"}
if len(got) != len(want) {
t.Fatalf("judged %+v, want a, b, c, e, f and g", got)
t.Fatalf("judged %+v, want a, b, c, e, f, g, h and i", got)
}
for _, j := range got {
is := "passes"
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/requiredmodules
go 1.22
+204
View File
@@ -0,0 +1,204 @@
// Command requiredmodules refuses a Go module that imports a package of a mesh module its go.mod does not require
// (issue 1921, issue 1966; the second time a local workspace hid it: the candidate's build resolves only what go.mod says).
//
// A developer's go.work resolves every sibling module, so a source can import a package whose module the go.mod never
// names and still build; the build seat builds against the go.mod alone and then fails with "finding module for
// package". This check reads every go.mod under the roots it reports and every Go source beside it (test files and files
// behind a build tag included, testdata, vendor and node_modules left out) and, for each import under
// git.novox.be/novox/, finds the module that owns the package (the longest `module` line, among the modules of every
// root it is given, that is a prefix of the import) and demands that module to be the go.mod's own or one it requires.
// Where no known module owns the import, any own or required module that is a prefix of it satisfies it.
//
// go run . <root> [<root> ...]
//
// The first root is reported; the others (the repositories cloned beside, MESH_CHECK_BESIDE) only say which module owns
// which path. With -all every root is reported. Exits 1 on a finding.
package main
import (
"fmt"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const prefix = "git.novox.be/novox/"
// Mod is a go.mod: where it is, its module path and the modules it requires.
type Mod struct {
Dir string
Path string
Requires map[string]bool
}
// Modules reads every go.mod under root.
func Modules(root string) ([]Mod, error) {
var out []Mod
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case "testdata", "vendor", "node_modules", ".git":
return fs.SkipDir
}
return nil
}
if d.Name() != "go.mod" {
return nil
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
m := Mod{Dir: filepath.Dir(p), Requires: map[string]bool{}}
in := false
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if i := strings.Index(line, "//"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
f := strings.Fields(line)
switch {
case len(f) == 2 && f[0] == "module":
m.Path = strings.Trim(f[1], `"`)
case len(f) >= 1 && f[0] == "require" && len(f) >= 2 && f[1] == "(":
in = true
case line == ")":
in = false
case len(f) >= 3 && f[0] == "require":
m.Requires[f[1]] = true
case in && len(f) >= 2:
m.Requires[f[0]] = true
}
}
if m.Path == "" {
return fmt.Errorf("%s names no module", p)
}
out = append(out, m)
return nil
})
return out, err
}
func within(path, module string) bool { return path == module || strings.HasPrefix(path, module+"/") }
// Findings reports, for every go.mod under the report roots, each imported package whose module the go.mod neither is nor
// requires; known are the other roots that say which module owns a path.
func Findings(report, known []string) ([]string, error) {
var owners []string
var reported []Mod
for _, r := range report {
ms, err := Modules(r)
if err != nil {
return nil, err
}
reported = append(reported, ms...)
}
for _, r := range append(append([]string{}, report...), known...) {
ms, err := Modules(r)
if err != nil {
return nil, err
}
for _, m := range ms {
owners = append(owners, m.Path)
}
}
var out []string
for _, m := range reported {
seen := map[string]bool{}
err := filepath.WalkDir(m.Dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case "testdata", "vendor", "node_modules", ".git":
return fs.SkipDir
}
if p != m.Dir {
if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil {
return fs.SkipDir // a nested module is judged by its own go.mod
}
}
return nil
}
if !strings.HasSuffix(p, ".go") {
return nil
}
f, err := parser.ParseFile(token.NewFileSet(), p, nil, parser.ImportsOnly)
if err != nil {
return fmt.Errorf("%s: %w", p, err)
}
for _, im := range f.Imports {
path, _ := strconv.Unquote(im.Path.Value)
if !strings.HasPrefix(path, prefix) || seen[path] {
continue
}
owner := ""
for _, o := range owners {
if within(path, o) && len(o) > len(owner) {
owner = o
}
}
ok := false
if owner != "" {
ok = owner == m.Path || m.Requires[owner]
} else {
ok = within(path, m.Path)
for r := range m.Requires {
ok = ok || within(path, r)
}
}
if !ok {
seen[path] = true
if owner == "" {
owner = "(its module)"
}
rel, _ := filepath.Rel(m.Dir, p)
out = append(out, fmt.Sprintf("%s: %s imports %s (module %s), which its go.mod does not require", filepath.Join(m.Dir, "go.mod"), rel, path, owner))
}
}
return nil
})
if err != nil {
return nil, err
}
}
sort.Strings(out)
return out, nil
}
func main() {
args := os.Args[1:]
all := len(args) > 0 && args[0] == "-all"
if all {
args = args[1:]
}
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: requiredmodules [-all] <root> [<root> ...]")
os.Exit(2)
}
report, known := args[:1], args[1:]
if all {
report, known = args, nil
}
found, err := Findings(report, known)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("every module requires the mesh modules it imports")
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func tree(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for p, c := range files {
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(c), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
const aMod = "module git.novox.be/novox/r/a\n\ngo 1.22\n"
const bMod = "module git.novox.be/novox/r/b/sub\n\ngo 1.22\n"
// A module that imports a package of a mesh module its go.mod does not require is refused, in a test file and behind a
// build tag too; the module's own packages, a required module's and a nested module's (only when required) are not.
func TestAModuleImportsOnlyWhatItRequires(t *testing.T) {
src := func(imp string) string { return "package x\n\nimport _ \"" + imp + "\"\n" }
for _, row := range []struct {
name string
files map[string]string
want string
}{
{"its own package", map[string]string{"a/go.mod": aMod, "a/x.go": src("git.novox.be/novox/r/a/other")}, ""},
{"a required module", map[string]string{"a/go.mod": aMod + "\nrequire git.novox.be/novox/r/b/sub v0.0.0\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, ""},
{"a required module in a block", map[string]string{"a/go.mod": aMod + "\nrequire (\n\tgit.novox.be/novox/r/b/sub v0.0.0 // indirect\n)\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, ""},
{"a module not required", map[string]string{"a/go.mod": aMod, "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x.go imports git.novox.be/novox/r/b/sub/pkg"},
{"a test file", map[string]string{"a/go.mod": aMod, "a/x_test.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x_test.go imports"},
{"an integration file", map[string]string{"a/go.mod": aMod, "a/x.go": "//go:build integration\n\n" + src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x.go imports"},
{"a module the longest owner is not", map[string]string{"a/go.mod": aMod + "\nrequire git.novox.be/novox/r/b v0.0.0\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": "module git.novox.be/novox/r/b\n", "b/sub/go.mod": bMod}, "does not require"},
{"a testdata fixture", map[string]string{"a/go.mod": aMod, "a/testdata/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "a/x.go": "package x\n"}, ""},
{"an outside module", map[string]string{"a/go.mod": aMod, "a/x.go": src("github.com/x/y")}, ""},
} {
t.Run(row.name, func(t *testing.T) {
got, err := Findings([]string{tree(t, row.files)}, nil)
if err != nil {
t.Fatal(err)
}
if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) {
t.Fatalf("findings = %q, want one containing %q", all, row.want)
}
})
}
}
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the repositories cloned beside it, when
// the check says where, tell which module owns a path.
func TestEveryCatalogueModuleRequiresWhatItImports(t *testing.T) {
var known []string
if beside := os.Getenv("MESH_CHECK_BESIDE"); beside != "" {
es, _ := os.ReadDir(beside)
for _, e := range es {
if e.IsDir() && e.Name() != "mesh-catalog" {
known = append(known, filepath.Join(beside, e.Name()))
}
}
}
got, err := Findings([]string{filepath.Join("..", "..")}, known)
if err != nil {
t.Fatal(err)
}
for _, f := range got {
t.Error(f)
}
}
+6
View File
@@ -0,0 +1,6 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every go.mod and Go source of the modules and of the checks, whose imports and requires are read.
modules
checks
@@ -43,7 +43,7 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Fatal(err)
}
// The builder's keys are read through its published package, taken by its series (ADR 0634 §1).
if m.Packages["builds"] != "0.1" {
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
}
for _, r := range m.Resources {
+1 -1
View File
@@ -2,7 +2,7 @@
"module": "artifact-store-tools",
"version": "1",
"packages": {
"builds": "0.1"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
},
"claims": [
{
+4 -4
View File
@@ -78,9 +78,9 @@ must see, a node that joins later included — kept, so it carries no secret eit
| what | how |
|---|---|
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206), and said whole as the event `holdings-changed` at start, on every change and again when the licence manager says `holdings.asked` (design 39 bus-and-switch §23) |
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
| what this node should hold | the licence manager's `binding.changed` naming this node, and at start (or a reconnect) its first view, `anthropic-licence-manager.bindings` with `consumer` this node; an event for another node is ignored. On a newer generation, at start, and on the vault's `secret-changed` for the licence it holds, this module asks `anthropic-licence-manager.current` which licence, and the vault's `get` for that licence's access token (`licence.<key>.access`), sealed to its own key, and writes it access-token-only — so the agent here never refreshes, and never receives a refresh token. A home holding no token is fetched again at the same generation (design 39 bus-and-switch §19, §22) |
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
@@ -150,8 +150,8 @@ record is how a habit that found a way round is seen.
## Tools
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence
manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull` (asks the vault for this node's
licence token now), `claude_code_grant` (for the licence manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
more).
**What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a
@@ -457,13 +457,9 @@ func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
p, w := node(t, "anchor")
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
ask := func(address string, args any) (json.RawMessage, error) {
key := args.(map[string]any)["public_key"].(string)
g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000})
box, _ := Seal(string(g), key)
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box,
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}})
}
var asked []string
ask := vaulted(t, Current{Licence: "personal", Key: "personal", Kind: "subscription", Generation: 4,
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}}, "at", &asked)
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
t.Fatal(err)
}
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -282,31 +283,52 @@ func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
}
}
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
// seat answers `current` as the manager does, the licence and its key and never a token, and `get` as the vault does:
// the licence's access secret sealed to the key the node sent (design 39 bus-and-switch §18, §19).
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
return vaulted(t, Current{Licence: licence, Key: licence, Kind: "subscription", Generation: gen}, token, asked)
}
// vaulted answers `current` with c and the vault's `get` of c's access secret with token.
func vaulted(t *testing.T, c Current, token string, asked *[]string) Ask {
return func(address string, args any) (json.RawMessage, error) {
*asked = append(*asked, address)
key := args.(map[string]any)["public_key"].(string)
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
box, err := Seal(string(g), key)
if err != nil {
t.Fatal(err)
a := args.(map[string]any)
switch address {
case SeatVerb("current"):
if _, sent := a["public_key"]; sent || a["consumer"] == nil {
t.Fatalf("current asked with %v", a)
}
return json.Marshal(c)
case VaultGet:
if a["name"] != AccessSecret(c.Key) {
t.Fatalf("the vault asked for %v", a["name"])
}
// Each answer a little later, as each rotation hands a later token.
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000 + int64(len(*asked))*1000})
box, err := Seal(string(g), a["public_key"].(string))
if err != nil {
t.Fatal(err)
}
return json.Marshal(map[string]any{"name": a["name"], "generation": 1, "sealed": box})
}
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
t.Fatalf("asked %s", address)
return nil, nil
}
}
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
// design 39 bus-and-switch §19: a newer generation asks the seat which licence, and the vault for its token.
func TestANewerGenerationFetchesTheTokenOnceFromTheVault(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
ask := seat(t, "personal", "at-1", 3, &asked)
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
if len(asked) != 2 || asked[0] != SeatVerb("current") || asked[1] != VaultGet || creds(t, p)["accessToken"] != "at-1" {
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
}
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 2 {
t.Fatal("an equal generation asked again")
}
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
@@ -497,30 +519,90 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
}
}
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
// design 39 bus-and-switch §19, B11: only a newer generation than the one applied is fetched; a lower one is not, and
// the vault's secret-changed for the licence held asks its token again, for another secret nothing.
func TestTheTokenIsAskedOnANewerGenerationAndOnTheVaultsChange(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
t.Fatal(err)
}
// The rotation that came with it: a later token, as a refresh hands one over.
later := func(address string, args any) (json.RawMessage, error) {
asked = append(asked, address)
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
if err != nil {
t.Fatal(err)
}
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
if done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
seat(t, "personal", "at-low", 3, &asked), writer(w)); err != nil || done != "" || len(asked) != 2 {
t.Fatalf("a lower generation was fetched: %q %v, asked %v", done, err, asked)
}
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
later, writer(w)); err != nil {
rotated := seat(t, "personal", "at-new", 16, &asked)
for _, other := range []string{`{"name":"licence.personal.grant","generation":5}`, `{"name":"licence.other.access","generation":5}`} {
if done, err := OnSecretChanged(p, []byte(other), rotated, writer(w)); err != nil || done != "" || len(asked) != 2 {
t.Fatalf("%s asked again: %q %v, asked %v", other, done, err, asked)
}
}
if _, err := OnSecretChanged(p, []byte(`{"name":"licence.personal.access","generation":5,"by":"x","at":"2026-11-11T00:00:00Z"}`),
rotated, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
if len(asked) != 4 || asked[3] != VaultGet || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 16 {
t.Fatalf("asked %v, credentials %v: the vault's change was not applied", asked, creds(t, p))
}
}
// design 39 bus-and-switch §22/AC1, B22: binding.changed, decoded from a copy of the manager's form, is acted on only
// when it names this node's consumer; an event for another consumer is ignored.
func TestAnEventForAnotherConsumerIsIgnored(t *testing.T) {
p, w := node(t, "server")
var asked []string
ask := seat(t, "api", "at-3", 3, &asked)
said, err := os.ReadFile("testdata/binding-changed.json")
if err != nil {
t.Fatal(err)
}
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || done != "" || len(asked) != 0 {
t.Fatalf("an event for laptop was acted on by server: %q %v, asked %v", done, err, asked)
}
p, w = node(t, "laptop")
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":3`) || len(asked) != 2 {
t.Fatalf("laptop's own event: %q %v, asked %v", done, err, asked)
}
if done, _ := OnBindingChanged(p, []byte(`{"consumer":"laptop","licence":"","generation":3,"from":"api"}`), ask, writer(w)); !strings.Contains(done, "released") {
t.Fatalf("a release said %q", done)
}
}
// design 39 bus-and-switch §22, B10: at its start the module asks the seat's bindings for its own consumer and applies
// that first view; its manifest consumes binding.changed and no longer reads the manager's state.
func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
current := vaulted(t, Current{Licence: "api", Key: "api", Kind: "subscription", Generation: 9}, "at-9", &asked)
ask := func(address string, args any) (json.RawMessage, error) {
if address == SeatVerb("bindings") {
asked = append(asked, address)
if args.(map[string]any)["consumer"] != "laptop" {
t.Fatalf("bindings asked with %v", args)
}
return json.RawMessage(`[{"consumer":"laptop","licence":"api","key":"api","kind":"api-key","generation":9,"why":"carried over","by":"claude-licence-manager"}]`), nil
}
return current(address, args)
}
if done, err := FirstBinding(p, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":9`) {
t.Fatalf("the first view: %q %v, asked %v", done, err, asked)
}
if len(asked) != 3 || asked[0] != SeatVerb("bindings") || creds(t, p)["accessToken"] != "at-9" {
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
}
// §19: at every start the vault is asked, though the binding is the one applied.
if _, err := FirstBinding(p, ask, writer(w)); err != nil || len(asked) != 6 || asked[5] != VaultGet {
t.Fatalf("a start asked %v", asked)
}
raw, _ := os.ReadFile("../../module.json")
var m struct {
Consumes, Invokes []string
Reads []string
}
_ = json.Unmarshal(raw, &m)
if !slices.Contains(m.Consumes, BindingChanged) || !slices.Contains(m.Consumes, SecretChanged) ||
!slices.Contains(m.Invokes, SeatVerb("bindings")) || !slices.Contains(m.Invokes, VaultGet) || len(m.Reads) != 0 {
t.Fatalf("the manifest consumes %v, invokes %v, reads %v", m.Consumes, m.Invokes, m.Reads)
}
}
@@ -276,10 +276,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
}
_ = json.Unmarshal(raw, &m)
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed.
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) ||
!slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) ||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) {
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The
// licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9).
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet}) ||
len(m.Reads) != 0 ||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked, BindingChanged, SecretChanged}) {
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
}
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
@@ -0,0 +1,37 @@
package main
// What this node holds, said (design 39 bus-and-switch §23): kept in this module's own state `holdings` and said as
// the event `holdings-changed`, the holdings report whole and never a token, at start, whenever it changes, and again
// when the licence manager says `holdings.asked`.
import "encoding/json"
// HoldingsChanged is the event that says what a node holds.
const HoldingsChanged = "holdings-changed"
// HoldingsAsked is the licence manager's event asking every node to say what it holds.
const HoldingsAsked = Manager + ".holdings.asked"
// HoldingsSayer keeps and says what this node holds.
type HoldingsSayer struct {
Put func(Holdings) error
Emit func(Holdings) error
said string
}
// Say keeps and says now when it differs from what was said last, or always when again: true when it said it. A
// failure says nothing was said, so the next call tries again.
func (s *HoldingsSayer) Say(now Holdings, again bool) (bool, error) {
raw, _ := json.Marshal(now)
if !again && string(raw) == s.said {
return false, nil
}
if err := s.Put(now); err != nil {
return false, err
}
if err := s.Emit(now); err != nil {
return false, err
}
s.said = string(raw)
return true, nil
}
@@ -0,0 +1,52 @@
package main
import (
"encoding/json"
"errors"
"strings"
"testing"
)
// design 39 bus-and-switch §23: the holdings are said at start, on a change and when asked again, kept in the
// module's own state each time, the report whole and never a token.
func TestHoldingsAreSaidAtStartAndOnChange(t *testing.T) {
var kept, said []string
failing := false
s := &HoldingsSayer{
Put: func(h Holdings) error { raw, _ := json.Marshal(h); kept = append(kept, string(raw)); return nil },
Emit: func(h Holdings) error {
if failing {
return errors.New("no responders")
}
raw, _ := json.Marshal(h)
said = append(said, string(raw))
return nil
},
}
fp := "sha256:0123456789abcdef"
start := Holdings{Node: "laptop"}
changed := Holdings{Node: "laptop"}
changed.Refresh.Present, changed.Refresh.Fingerprint = true, &fp
for i, step := range []struct {
h Holdings
again, out bool
}{{start, false, true}, {start, false, false}, {changed, false, true}, {changed, true, true}} {
if got, err := s.Say(step.h, step.again); err != nil || got != step.out {
t.Fatalf("step %d: said %v (%v), want %v", i, got, err, step.out)
}
}
if len(said) != 3 || len(kept) != 3 {
t.Fatalf("said %d, kept %d, want 3 each", len(said), len(kept))
}
if !strings.Contains(said[1], fp) || !strings.Contains(said[1], `"node":"laptop"`) {
t.Fatalf("the report is not said whole: %s", said[1])
}
failing = true
if got, err := s.Say(start, false); err == nil || got {
t.Fatal("a failed event counts as said")
}
failing = false
if got, _ := s.Say(start, false); !got {
t.Fatal("what failed to be said is not said at the next call")
}
}
@@ -109,7 +109,9 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
t.Errorf("replacements: %s", got)
}
t.Run("the again tool answers the operator and the healers only", func(t *testing.T) {
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.mesh-healers": false, "shanks.mesh-config": true} {
// An operator's session through the mesh MCP server is proved as its machine's tool runner (issue 627, issue 1966).
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.node-tools": false, "shanks.mesh-healers": false,
"shanks.mesh-config": true} {
if err := seatlist.Refusal("claude_code_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused {
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
}
@@ -9,5 +9,5 @@ func instructionsText(node, role string) string {
node +
"`\n- **Role:** " +
role +
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}
+65 -33
View File
@@ -20,6 +20,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
@@ -345,7 +346,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
return GuardStatus(p, last), nil
}},
{Name: "claude_code_pull",
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
Description: "Ask the licence manager which licence this node holds and the vault for its access token now, and apply it, rather than waiting for its binding or the token to change.",
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
{Name: "claude_code_grant",
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
@@ -567,15 +568,24 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
// the old inode would go quiet. Fingerprints and expiries only.
holdings := stdio.State("holdings")
reported := ""
report := func() {
var sayMu sync.Mutex
sayer := &HoldingsSayer{
Put: func(h Holdings) error { _, err := holdings.Put(p.Node, h); return err },
Emit: func(h Holdings) error { return stdio.Emit(HoldingsChanged, h) },
}
sayHoldings := func(again bool) {
sayMu.Lock()
defer sayMu.Unlock()
now := HoldingsOf(p)
raw, _ := json.Marshal(now)
if string(raw) == reported {
return
}
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
reported = string(raw)
said := false
persist("reporting what this node holds", func() error {
var err error
said, err = sayer.Say(now, again)
return err
}, func(int) {
if !said {
return
}
account := "no account"
if now.Identity != nil && now.Identity.EmailAddress != "" {
account = now.Identity.EmailAddress
@@ -593,30 +603,50 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
say("%s", line)
})
}
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
// generation is fetched with the seat's `current`, sealed to this module's key.
go persist("watching this node's licence binding", func() error {
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
if c.Key != p.Node {
return nil
}
var b *BindingState
if c.Op == "put" {
b = &BindingState{}
if err := json.Unmarshal(c.Value, b); err != nil {
return nil
}
}
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
say("fetching this node's token failed: %v", err)
} else if done != "" {
say("%s", done)
}
go report()
report := func() { sayHoldings(false) }
// The licence manager asks every node to say what it holds again (§23): after its carry-over, at its start and on
// its resync.
go persist("hearing the licence manager ask what this node holds", func() error {
return stdio.Subscribe(HoldingsAsked, func(stdio.Envelope) error {
go sayHoldings(true)
return nil
})
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
}, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) })
// What this node should hold (design 39 bus-and-switch §19, §22): the manager's binding.changed naming this node's
// consumer, and its first view, the seat's bindings for this consumer, asked once each time the events are heard
// anew (the start, or a reconnect); the token is asked from the vault at that first view and on a newer generation.
applied := func(done string, err error) {
if err != nil {
say("fetching this node's token failed: %v", err)
} else if done != "" {
say("%s", done)
}
go report()
}
go persist("hearing this node's licence binding", func() error {
err := stdio.Subscribe(BindingChanged, func(e stdio.Envelope) error {
applied(OnBindingChanged(p, e.Body, ask, writeManaged))
return nil
})
if err == nil {
go persist("asking this node's licence binding", func() error {
done, err := FirstBinding(p, ask, writeManaged)
if err == nil {
applied(done, nil)
}
return err
}, func(int) {})
}
return err
}, func(n int) { say("hearing this node's licence binding%s", refusals(n)) })
// The token of the licence this node holds, asked again when the vault says its access secret changed (§19).
go persist("hearing the vault's changes of this node's licence token", func() error {
return stdio.Subscribe(SecretChanged, func(e stdio.Envelope) error {
applied(OnSecretChanged(p, e.Body, ask, writeManaged))
return nil
})
}, func(n int) { say("hearing %s%s", SecretChanged, refusals(n)) })
report()
var last string
@@ -667,8 +697,10 @@ func refusals(n int) string {
return fmt.Sprintf(" (after %d refusal(s))", n)
}
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7).
var seatListAgainCallers = []string{stdio.AnyPerson, "mesh-healers"}
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7). A call through
// the mesh MCP server is proved only as its machine's tool runner (<node>.node-tools, issue 627), so node-tools stands for
// an operator's session until a session is proved (issue 1966).
var seatListAgainCallers = []string{stdio.AnyPerson, "node-tools", "mesh-healers"}
// seatListTools are the seat list reader's two tools: what the list holds and what it waits for, and a read now. The
// reader starts once the runtime serves, after the tools are listed, so each tool finds it when it is called.
+124 -28
View File
@@ -10,8 +10,10 @@ package main
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
// - What this node should hold is the manager's binding, heard as `binding.changed` and asked at start; the
// seat's `current` says which licence, and its `access` secret is asked from the vault, sealed to this
// module's key, at start, on a newer generation and on the vault's `secret-changed` for it, and written
// access-token-only (design 39 bus-and-switch §19).
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
// every node, `<node>.<server>` for one — which every node watches.
@@ -30,12 +32,23 @@ import (
"time"
)
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
// Seat is the licence manager's role, and Manager the module whose events this one consumes.
const (
Seat = "anthropic-licence-manager"
Manager = "claude-licence-manager"
)
// VaultGet is the vault's verb a licence's access secret is asked by, and SecretChanged the vault's event that it
// changed (design 39 bus-and-switch §19, B9).
const (
VaultGet = "seat:mesh-vault.get"
SecretChanged = "mesh-vault.secret-changed"
)
// AccessSecret is the name of a licence's access secret in the vault, by the licence's key as the manager says it
// (§15, B7: the key is carried, never formed here).
func AccessSecret(key string) string { return "licence." + key + ".access" }
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
@@ -309,13 +322,14 @@ type BindingState struct {
By string `json:"by,omitempty"`
}
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
// Current is what the seat answers to `current`: the licence this node is bound to, its key and its account, never
// its token (§18, B7).
type Current struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
Sealed *SealedBox `json:"sealed"`
Identity *Identity `json:"identity"`
Licence string `json:"licence"`
Key string `json:"key"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
Identity *Identity `json:"identity"`
}
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
@@ -404,40 +418,50 @@ func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
}
// Pull asks the seat for this node's current token and applies it.
// Pull asks the seat which licence this node holds, then the vault for that licence's access secret, sealed to this
// module's key, and applies it (§19).
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
keys, err := Keypair(p)
if err != nil {
return nil, err
}
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node})
if err != nil {
return nil, err
}
var c Current
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
if err := json.Unmarshal(raw, &c); err != nil || c.Licence == "" || c.Key == "" {
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
}
return Apply(p, c, write)
raw, err = ask(VaultGet, map[string]any{"name": AccessSecret(c.Key), "public_key": keys.PublicKey})
if err != nil {
return nil, err
}
var got struct {
Sealed *SealedBox `json:"sealed"`
}
if err := json.Unmarshal(raw, &got); err != nil || got.Sealed == nil {
return nil, fmt.Errorf("the vault answered no sealed %s", AccessSecret(c.Key))
}
return Apply(p, c, *got.Sealed, write)
}
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
// lives hours, and says so.
//
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
// a node that waited for a number above its own ignored every binding it was sent, its login and the
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
// OnBinding takes this node's binding as the manager said it: the token is fetched when its generation is newer than
// the one applied (§19). A home that holds no token of the applied kind has nothing applied, so it is fetched too. A
// released binding keeps the last token, which lives hours, and says so.
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
if b == nil {
return "this node's binding was released; it keeps its last token until it expires", nil
}
var applied Binding
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence &&
holdsApplied(p, applied.Kind) {
if readJSON(p.binding(), &applied) && b.Generation <= applied.Generation && holdsApplied(p, applied.Kind) {
return "", nil
}
return fetched(p, b, ask, write)
}
// fetched pulls this node's token for binding b and says the line naming it.
func fetched(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
out, err := Pull(p, ask, write)
if err != nil {
return "", err
@@ -455,14 +479,86 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
return string(raw), nil
}
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
// BindingChanged is the manager's event of a binding written or deleted (design 39 bus-and-switch §21), which this
// module consumes (§22).
const BindingChanged = Manager + ".binding.changed"
// bindingOf is a consumer's binding as the manager says it, in binding.changed and in the verb bindings' answer: an
// empty licence is a release (B22: decoded from a copy of the writer's form, testdata/binding-changed.json).
type bindingOf struct {
Consumer string `json:"consumer"`
BindingState
}
func (b bindingOf) state() *BindingState {
if b.Licence == "" {
return nil
}
return &b.BindingState
}
// OnBindingChanged takes one binding.changed: acted on only when it names this node's consumer (§22).
func OnBindingChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
var b bindingOf
if err := json.Unmarshal(body, &b); err != nil {
return "", fmt.Errorf("%s is not a binding's change: %w", BindingChanged, err)
}
if b.Consumer != p.Node {
return "", nil
}
return OnBinding(p, b.state(), ask, write)
}
// FirstBinding asks the seat's verb bindings for this node's consumer, its first view (§22, B10), and applies it: at
// the module's start, and again whenever it hears the manager's events anew.
func FirstBinding(p Paths, ask Ask, write WriteManaged) (string, error) {
raw, err := ask(SeatVerb("bindings"), map[string]any{"consumer": p.Node})
if err != nil {
return "", err
}
var listed []bindingOf
if err := json.Unmarshal(raw, &listed); err != nil {
return "", fmt.Errorf("the seat's bindings answered what is not a list of bindings: %w", err)
}
for _, b := range listed {
if b.Consumer == p.Node && b.state() != nil {
// At its start the module asks the vault whatever it applied before (§19).
return fetched(p, b.state(), ask, write)
}
}
return OnBinding(p, nil, ask, write)
}
// secretChanged is the vault's `secret-changed` as it says it: the secret's name and generation, never its value.
type secretChanged struct {
Name string `json:"name"`
Generation int64 `json:"generation"`
}
// OnSecretChanged takes one `secret-changed`: acted on only when it names the access secret of the licence this node
// holds, which is then asked again (§19).
func OnSecretChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
var e secretChanged
if err := json.Unmarshal(body, &e); err != nil {
return "", fmt.Errorf("%s is not a secret's change: %w", SecretChanged, err)
}
var applied Binding
if !readJSON(p.binding(), &applied) || applied.Key == "" || e.Name != AccessSecret(applied.Key) {
return "", nil
}
return fetched(p, &BindingState{Licence: applied.Licence, Kind: applied.Kind, Generation: applied.Generation,
Why: "the vault's " + e.Name + " changed"}, ask, write)
}
// Apply applies the access secret the vault handed over for the licence the seat named. A switch replaces the grant
// whole and cleans up after the old licence; whatever it is, the file is written without a refresh token, so the
// agent here never refreshes.
func Apply(p Paths, c Current, sealed SealedBox, write WriteManaged) (map[string]any, error) {
keys, err := Keypair(p)
if err != nil {
return nil, err
}
plain, err := Open(*c.Sealed, keys.PrivateKey)
plain, err := Open(sealed, keys.PrivateKey)
if err != nil {
return nil, err
}
@@ -522,7 +618,7 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
if gen == 0 {
gen = previous.Generation
}
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
raw, _ := json.Marshal(Binding{Licence: c.Licence, Key: c.Key, Kind: c.Kind, Generation: gen})
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
return nil, err
}
@@ -50,7 +50,9 @@ type Settings struct {
// Binding is the licence this node holds, as it was last applied.
type Binding struct {
Licence string `json:"licence"`
Licence string `json:"licence"`
// Key is the licence's key as the manager says it, which names its access secret in the vault (§19, B7).
Key string `json:"key,omitempty"`
Kind string `json:"kind"` // subscription | api-key
Generation int64 `json:"generation,omitempty"`
}
@@ -0,0 +1 @@
{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"}
@@ -32,11 +32,11 @@
"withKey": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
},
"plain": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}
}
+10 -6
View File
@@ -28,14 +28,20 @@
"seat:mesh-catalog.seat-definitions",
"mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-assigner.assigner_read_assignments"
"mesh-delivery-assigner.assigner_read_assignments",
"seat:anthropic-licence-manager.bindings",
"seat:anthropic-licence-manager.current",
"seat:mesh-vault.get"
],
"consumes": [
"mesh-delivery-indexer.views.modules",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-assigner.views.assignments",
"mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed"
"mesh-catalog.seat-definition-removed",
"claude-licence-manager.holdings.asked",
"claude-licence-manager.binding.changed",
"mesh-vault.secret-changed"
],
"state": [
"servers",
@@ -48,10 +54,8 @@
"findings"
],
"emits": [
"judged"
],
"reads": [
"claude-licence-manager.bindings"
"judged",
"holdings-changed"
],
"tools": [
"claude_code_status",
+72 -27
View File
@@ -1,58 +1,103 @@
# claude-licence-manager
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39).
rotation source, its consumers moved off a licence near its limit by the manager itself (novox/hq ADR 0183,
ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module
keeps none.
## How a licence comes to exist
Nothing is configured. Every node running `claude-code` reports what it holds as that module's
`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report
when it starts and watches them:
Nothing is configured. Every node running `claude-code` says what it holds as the event
`claude-code.holdings-changed` (the account, fingerprints and expiries, never a token), at its start, on every change,
and again when this module says `holdings.asked` (at every start, after the carry-over, and on `licences_resync`):
1. A report with a refresh token it does not hold is a **candidate**.
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest
with the key the vault made for it — and from then on it is the only refresher. If not, the candidate
is recorded dead and nothing is adopted.
3. **It refreshes it**, under the account's lease. If the vendor exchanges the token, the licence is new, named by its
account's address and kept under that name's key (`@` as `_at_`, any other character outside letters, digits, `-`
and `.` as `_`); its grant and access token go to the vault (`licence-put`), and from then on this module is the
only refresher. If not, the candidate is recorded dead and nothing is adopted. An adoption whose key another licence
has is refused.
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
5. A node reporting that account and bound to nothing is bound to it.
5. The node it came from is bound to it; a node reporting that account and bound to nothing is bound to it.
Each node is then handed an access token only, so the agent there never refreshes, and a refresh token
appearing on a node later can only be a person's login — which wins if it refreshes.
From its next reading a new subscription has a room and is a target of the switch. An API key enters through `adopt`,
from a file on this module's node or sealed by a node's `claude_code_add_api_key`; it is never a target.
An API key enters through `adopt`, from a file on this module's node.
## What it keeps on the bus
## What each consumer holds
Each piece asked for in `requires` by its template (ADR 0595), read by `licences_kept`:
This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a
generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer
generation, asks `current` with its public key.
| name | template | what |
|---|---|---|
| `licences` | state, history 1 | one key per licence: account, address, organisation, expiries, failures, adoption, the vault's generation, the latest reading; never a token |
| `bindings` | state, history 8 | one key per consumer (a node's name): licence, kind, generation, since, why and by |
| `leases` | state, a lifetime per key | `licence.<key>`, `account.<id>`: one refresh or adoption at a time, two minutes |
| `offered` | state, a lifetime per key | each grant a node offered and its outcome, 90 days |
| `usage` | log, 35 days, 256 MiB | every reading, one subject per licence key, with the vendor's answer whole |
| `audit` | log, 64 MiB | every act, one subject per kind (`switched`, `carried`, `settings` among them) |
| `carried` | state | `postgres`: when the carry-over ran, and per table the rows read and entries written |
| `conditions` | state | each condition said raised, and since when |
Its database (`postgres-database`, read with the module's own credential) and `grant.key` are kept only as the
carry-over's source: at its first start, before it hears an event, refreshes or reads usage, the manager reads every
row of today's six tables and writes them here and to the vault, then `carried.postgres`. Until then it serves its
reads alone and answers every act with "the carry-over has not ended"; a failed read or write raises
`carry-over-failed`. With `carried.postgres` present it never reads the database again. The database is never written.
## Switching on usage
Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session,
weekly and sonnet windows, each with its reset time. A licence's room is 100 less the higher of its session and weekly
percentages, known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
a licence with consumers at or above `switch_at_pct`, every consumer bound to it moves, in one act, to the licence
with the most room whose windows are both below `room_below_pct`: one `binding.changed` per consumer, one
`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule.
Its conditions are said on the operator channel's condition subject, the router deciding where they go:
`licence.<key>.no-room` (urgent; no licence has room), `licence.<key>.near-limit` (at `warn_at_pct`, once per
crossing), `licence.<key>.usage-unread`, `carry-over-failed` and `settings.<setting>.invalid`.
## The seat's verbs
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
`licences`, `bindings` (with `consumer` for one), `bind`, `switch`, `release`, `refresh`, `usage` (`licence`,
`since`, `limit` up to 2000, `raw`), `adopt`, `public-key`, `current` (the licence, its key, kind, generation and
health, never the token) — through the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
## Its tools
`licences_switches` (every switch and why), `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the
rule now, even with `switch_auto` off), `licences_settings` (each value, its default and where it was set),
`licences_kept` (every entry it keeps, how full against its cap), `licences_resync` (say every binding, holdings.asked
and each condition again, and hand each access token to the vault again), `licences_conditions`, `licences_carried`
(the carry-over's record; `run` while it has not ended).
## Settings
`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60),
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3).
Through mesh-config, each `trusted`, read again at every reading: `cadence_minutes` (240), `floor_minutes` (60),
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3), `switch_auto` (on), `switch_at_pct` (99,
1 to 100), `room_below_pct` (90, 1 to 100), `warn_at_pct` (90, 1 to 100), `usage_minutes` (5, 1 to 60),
`usage_stale_minutes` (15, 5 to 180). A value outside its bound keeps its default and raises its condition.
## Events
`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret.
`licence.adopted`, `licence.refused`, `licence.switched`, `licence.failing`, `usage.read`, `binding.changed`,
`holdings.asked` — none carries a secret. It consumes `claude-code.holdings-changed`.
## Code and tests
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle,
`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte —
the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the
Go one replaced, so the format is the one already on the machines.
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs, the tools and the daemon in one launched bundle. The
sealed box is `claude-code`'s own format, byte for byte — the two modules carry the same `seal.go` — and a test opens
one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines.
go test ./...
# the store against a real postgres:
docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
# the whole module on a real bus, and today's rows on a real postgres:
go test -tags integration ./...
The whole tests (`whole_integration_test.go`) start the built binary as its runtime does, its kept data in a real
nats-server, and today's database on `MESH_TEST_POSTGRES` or a private postgres started from the machine's `initdb`
on a socket of its own; with neither, the tests that need one skip and say so. The vault, the agent modules, the
operator channel and the vendor are played beside it; a condition is read through the operator channel's client.
## Files it does not know
@@ -0,0 +1,377 @@
package main
// The manager's kept data on the bus (design 39 bus-and-switch §2, §3, B11, B22): the state `licences`, one key per
// licence by its key form (§10), and the state `bindings`, one key per consumer, its value the form the consumers
// have always read. Written by the manager alone.
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// keptState is a state this module keeps, as mesh-sdk's KeptState answers it.
type keptState interface {
Get(key string) (*stdio.StateEntry, error)
Put(key string, value any) (uint64, error)
PutTTL(key string, value any, ttl time.Duration) (uint64, error)
Create(key string, value any) (uint64, error)
CreateTTL(key string, value any, ttl time.Duration) (uint64, error)
Delete(key string) error
Keys() ([]string, error)
History(key string) ([]stdio.StateRevision, error)
}
// BindingState is the value of a consumer's key in `bindings` (§3, B22: the form 0 its readers decode): no secret,
// only what it should hold, which generation, since when, why and by whom.
type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
Since string `json:"since,omitempty"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// BusStore keeps the manager's data in the bus's own storage.
type BusStore struct {
LicencesState keptState
BindingsState keptState
LeasesState keptState
OfferedState keptState
UsageLog keptLog
AuditLog keptLog
Now func() time.Time
}
func (s *BusStore) get(st keptState, key string, into any) (bool, error) {
e, err := st.Get(key)
if err != nil || e == nil || len(e.Value) == 0 || string(e.Value) == "null" {
return false, err
}
return true, json.Unmarshal(e.Value, into)
}
func (s *BusStore) Licences(context.Context) ([]Licence, error) {
keys, err := s.LicencesState.Keys()
if err != nil {
return nil, err
}
out := []Licence{}
for _, k := range keys {
var l Licence
if ok, err := s.get(s.LicencesState, k, &l); err != nil {
return nil, err
} else if ok {
out = append(out, l)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (s *BusStore) Licence(_ context.Context, name string) (*Licence, error) {
var l Licence
ok, err := s.get(s.LicencesState, KeyFor(name), &l)
if err != nil || !ok || l.Name != name {
return nil, err
}
return &l, nil
}
func (s *BusStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
all, err := s.Licences(ctx)
if err != nil {
return nil, err
}
for _, l := range all {
if l.AccountUUID == account {
return &l, nil
}
}
return nil, nil
}
func (s *BusStore) SaveLicence(_ context.Context, l Licence) error {
if l.Name == "" {
return errors.New("a licence has a name")
}
_, err := s.LicencesState.Put(KeyFor(l.Name), l)
return err
}
func (s *BusStore) Bindings(context.Context) ([]Binding, error) {
keys, err := s.BindingsState.Keys()
if err != nil {
return nil, err
}
out := []Binding{}
for _, k := range keys {
if b, err := s.binding(k); err != nil {
return nil, err
} else if b != nil {
out = append(out, *b)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (s *BusStore) binding(consumer string) (*Binding, error) {
var v BindingState
ok, err := s.get(s.BindingsState, consumer, &v)
if err != nil || !ok {
return nil, err
}
return &Binding{Consumer: consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Since: v.Since,
Why: v.Why, By: v.By}, nil
}
func (s *BusStore) Binding(_ context.Context, consumer string) (*Binding, error) {
return s.binding(consumer)
}
// Bind writes a consumer's key at its next generation: one more than the higher of its own and the floor (B11).
func (s *BusStore) Bind(_ context.Context, consumer, licence string, m Move) (Binding, error) {
before, err := s.binding(consumer)
if err != nil {
return Binding{}, err
}
generation := m.floor(consumer)
if before != nil {
generation = max(generation, before.Generation)
}
b := Binding{Consumer: consumer, Licence: licence, Kind: m.Kind, Generation: generation + 1,
Since: s.Now().UTC().Format(time.RFC3339), Why: m.Why, By: m.By}
_, err = s.BindingsState.Put(consumer, BindingState{Licence: b.Licence, Kind: b.Kind, Generation: b.Generation,
Since: b.Since, Why: b.Why, By: b.By})
return b, err
}
func (s *BusStore) Unbind(_ context.Context, consumer string) (bool, error) {
before, err := s.binding(consumer)
if err != nil || before == nil {
return false, err
}
return true, s.BindingsState.Delete(consumer)
}
// Advance gives every consumer bound to licence its next generation, keeping its licence and since.
func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Binding, error) {
all, err := s.Bindings(ctx)
if err != nil {
return nil, err
}
var out []Binding
for _, b := range all {
if b.Licence != licence {
continue
}
b.Generation = max(b.Generation, m.floor(b.Consumer)) + 1
b.Kind, b.Why, b.By = m.Kind, m.Why, m.By
if _, err := s.BindingsState.Put(b.Consumer, BindingState{Licence: b.Licence, Kind: b.Kind,
Generation: b.Generation, Since: b.Since, Why: b.Why, By: b.By}); err != nil {
return out, err
}
out = append(out, b)
}
return out, nil
}
// ---- leases and offers (§4, §5) ---------------------------------------------------------------------------------
// leaseValue is a lease's key in `leases`: its holder and until when.
type leaseValue struct {
Holder string `json:"holder"`
Until string `json:"until"`
}
// Lease takes a lease by creating its key with a lifetime of d (§4): a create fails while another holds it, the bus's
// own compare-and-set. A key its own holder holds is taken again.
func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
v := leaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)}
_, err := s.LeasesState.CreateTTL(key, v, d)
if err == nil {
return true, nil
}
if !errors.Is(err, stdio.ErrChanged) {
return false, err
}
var held leaseValue
if ok, gerr := s.get(s.LeasesState, key, &held); gerr != nil || !ok || held.Holder != holder {
return false, gerr
}
_, err = s.LeasesState.PutTTL(key, v, d)
return err == nil, err
}
// Unlease deletes a lease its holder holds; another's is left to it or its lifetime.
func (s *BusStore) Unlease(_ context.Context, key, holder string) error {
var held leaseValue
if ok, err := s.get(s.LeasesState, key, &held); err != nil || !ok || held.Holder != holder {
return err
}
return s.LeasesState.Delete(key)
}
// offeredFor is how long the outcome of an offered grant is kept (§5): longer than a refresh token lives.
const offeredFor = 90 * 24 * time.Hour
// offeredValue is a grant's key in `offered`: what became of it, where and when (§5, B13: every outcome).
type offeredValue struct {
Node string `json:"node"`
Account string `json:"account"`
Outcome Outcome `json:"outcome"`
Why string `json:"why"`
At string `json:"at"`
}
// OfferedKey is a fingerprint's key in `offered`: through the licence key form (B13).
func OfferedKey(fingerprint string) string { return KeyFor(fingerprint) }
func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, error) {
var v offeredValue
if ok, err := s.get(s.OfferedState, OfferedKey(fingerprint), &v); err != nil || !ok {
return "", err
}
return v.Outcome, nil
}
func (s *BusStore) RecordOutcome(_ context.Context, fingerprint, node, account string, o Outcome, why string) error {
return s.putOffered(fingerprint, offeredValue{Node: node, Account: account, Outcome: o, Why: why,
At: s.Now().UTC().Format(time.RFC3339)}, offeredFor)
}
func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Duration) error {
_, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl)
return err
}
// ---- the logs (§6, §7) ------------------------------------------------------------------------------------------
// keptLog is a log this module keeps, as mesh-sdk's KeptLog answers it.
type keptLog interface {
Append(key string, value any) (uint64, error)
Read(from uint64, keys string, limit int) (*stdio.LogPage, error)
Last(key string) (*stdio.LogEntry, error)
Info() (*stdio.LogInfo, error)
Newest(keys string) ([]stdio.LogEntry, error)
}
// UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054).
type UsageEntry struct {
At string `json:"at"`
Reading UsageReading `json:"reading"`
Raw map[string]any `json:"raw,omitempty"`
}
// AuditKinds are the subjects of the log `audit` (§7); an act of another kind is kept under `drift`.
var AuditKinds = []string{"adopted", "refused", "failed", "rotated", "bound", "switched", "released", "drift", "carried",
"settings"}
func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
_, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano),
Reading: r, Raw: raw})
return err
}
// Usage is the latest readings, newest first, of one licence or every one, none before since, the vendor's answer
// with raw (§35).
func (s *BusStore) Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error) {
names := map[string]string{}
all, err := s.Licences(ctx)
if err != nil {
return nil, err
}
for _, l := range all {
names[KeyFor(l.Name)] = l.Name
}
keys := ""
if licence != "" {
keys = KeyFor(licence)
}
entries, err := s.newest(s.UsageLog, keys, limit)
if err != nil {
return nil, err
}
out := []UsageRow{}
for _, e := range entries {
var u UsageEntry
if json.Unmarshal(e.Value, &u) != nil {
continue
}
at, _ := time.Parse(time.RFC3339Nano, u.At)
if at.Before(since) {
continue
}
name := names[e.Key]
if name == "" {
name = e.Key
}
row := UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading}
if raw {
row.Raw = u.Raw
}
out = append(out, row)
}
return out, nil
}
// newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles
// until it holds limit entries or reaches the first.
func (s *BusStore) newest(l keptLog, keys string, limit int) ([]stdio.LogEntry, error) {
info, err := l.Info()
if err != nil || info.Last == 0 {
return nil, err
}
for span := uint64(max(limit, 1)); ; span *= 2 {
from := uint64(1)
if info.Last > span {
from = info.Last - span + 1
}
var got []stdio.LogEntry
for next := from; next != 0; {
page, err := l.Read(next, keys, 0)
if err != nil {
return nil, err
}
got = append(got, page.Entries...)
next = page.Next
}
if len(got) >= limit || from <= max(info.First, 1) {
if len(got) > limit {
got = got[len(got)-limit:]
}
for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 {
got[i], got[j] = got[j], got[i]
}
return got, nil
}
}
}
// Audit appends one entry under its kind, with when; a log that does not take it fails the act (B20).
func (s *BusStore) Audit(_ context.Context, what string, detail map[string]any) error {
kind := "drift"
for _, k := range AuditKinds {
if k == what {
kind = k
}
}
entry := map[string]any{"at": s.Now().UTC().Format(time.RFC3339Nano)}
if kind != what {
entry["what"] = what
}
for k, v := range detail {
entry[k] = v
}
if _, err := s.AuditLog.Append(kind, entry); err != nil {
return fmt.Errorf("the audit did not take the %s entry, so the act is not done: %w", what, err)
}
return nil
}
@@ -0,0 +1,359 @@
package main
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// memState is a state of the bus as the runtime answers it, in memory: values, their past values, keys' lifetimes.
type memState struct {
mu sync.Mutex
now func() time.Time
values map[string]json.RawMessage
until map[string]time.Time
history map[string][]stdio.StateRevision
rev uint64
writes int
}
func newMemState(now func() time.Time) *memState {
return &memState{now: now, values: map[string]json.RawMessage{}, until: map[string]time.Time{},
history: map[string][]stdio.StateRevision{}}
}
func (s *memState) live(key string) bool {
if u, ok := s.until[key]; ok && !s.now().Before(u) {
delete(s.values, key)
delete(s.until, key)
}
_, ok := s.values[key]
return ok
}
func (s *memState) Get(key string) (*stdio.StateEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.live(key) {
return nil, nil
}
return &stdio.StateEntry{Key: key, Value: s.values[key]}, nil
}
func (s *memState) write(key string, value any, ttl time.Duration, create bool) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if create && s.live(key) {
return 0, stdio.ErrChanged
}
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
s.rev++
s.writes++
s.values[key] = raw
delete(s.until, key)
if ttl > 0 {
s.until[key] = s.now().Add(ttl)
}
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Value: raw, Revision: s.rev, Op: "put", At: s.now()})
return s.rev, nil
}
func (s *memState) Put(key string, value any) (uint64, error) { return s.write(key, value, 0, false) }
func (s *memState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) {
return s.write(key, value, ttl, false)
}
func (s *memState) Create(key string, value any) (uint64, error) { return s.write(key, value, 0, true) }
func (s *memState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
return s.write(key, value, ttl, true)
}
func (s *memState) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.rev++
s.writes++
delete(s.values, key)
delete(s.until, key)
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Revision: s.rev, Op: "delete", At: s.now()})
return nil
}
func (s *memState) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := []string{}
for k := range s.values {
if s.live(k) {
out = append(out, k)
}
}
sort.Strings(out)
return out, nil
}
func (s *memState) History(key string) ([]stdio.StateRevision, error) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]stdio.StateRevision(nil), s.history[key]...), nil
}
// design 39 bus-and-switch §2: a rotation and a reading each write the licence's key, by its key form, and no field
// holds a token.
func TestARotationAndAReadingWriteTheLicenceKey(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
key := KeyFor(licence1)
before := len(mm.licences.history[key])
if _, err := mm.m.Rotate(ctx, licence1, true); err != nil {
t.Fatal(err)
}
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
if got := len(mm.licences.history[key]); got < before+2 {
t.Fatalf("the key %s was written %d times by a rotation and a reading, want 2", key, got-before)
}
for _, r := range mm.licences.history[key] {
for _, token := range []string{"rt-", "at-"} {
if strings.Contains(string(r.Value), token) {
t.Fatalf("a value of %s holds %s: %s", key, token, r.Value)
}
}
}
var l Licence
_ = json.Unmarshal(mm.licences.values[key], &l)
if l.Name != licence1 || l.AccountUUID != "acct-1" || !strings.Contains(string(mm.licences.values[key]), `"account":"acct-1"`) {
t.Fatalf("the key holds %+v", l)
}
}
// design 39 bus-and-switch §3, B11: bind, switch, release and a rotation each write or delete the consumer's key with
// its why and by; each consumer counts its own generations, past what its node reported.
func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
mm.m.Reported = func(node string) int64 {
if node == "server" {
return 40
}
return 0
}
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "jane"})
if _, err := mm.m.Bind(asked, "server", "api"); err != nil {
t.Fatal(err)
}
if b := mm.bound("server"); b.Licence != "api" || b.Generation != 41 || b.Why != "the switch verb" || b.By != "jane" ||
b.Since == "" || b.Kind != "api-key" {
t.Fatalf("server's binding %+v", b)
}
laptop := mm.bound("laptop").Generation
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if b := mm.bound("laptop"); b.Generation != laptop+1 || b.Licence != "api" {
t.Fatalf("laptop's switch %+v, from generation %d", b, laptop)
}
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "jane"}), "server"); err != nil {
t.Fatal(err)
}
if _, ok := mm.bindings.values["server"]; ok {
t.Fatal("release left the key")
}
if h, _ := mm.bindings.History("server"); len(h) != 2 || h[1].Op != "delete" {
t.Fatalf("server's history %v", h)
}
}
// design 39 bus-and-switch §4, §5, B13: a lease is a key created with a two-minute lifetime that another holder cannot
// create; an offered grant's outcome is kept under its fingerprint in the key form for 90 days.
func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
key := "licence." + KeyFor(licence1)
if ok, err := mm.store.Lease(ctx, key, "one", leaseFor); !ok || err != nil {
t.Fatalf("the first lease: %v %v", ok, err)
}
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); ok {
t.Fatal("a second holder took a live lease")
}
if u := mm.leases.until[key]; !u.Equal(mm.now.Add(2 * time.Minute)) {
t.Fatalf("the lease lives until %v", u)
}
mm.now = mm.now.Add(2 * time.Minute)
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); !ok {
t.Fatal("a lease past its lifetime was not taken")
}
_ = mm.store.Unlease(ctx, key, "one")
if _, held := mm.leases.values[key]; !held {
t.Fatal("another holder's lease was ended")
}
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-dead", false, t0)})
fp := OfferedKey(Fingerprint("rt-dead"))
if strings.ContainsAny(fp, ":") || !strings.HasPrefix(fp, "sha256_") {
t.Fatalf("the offered key %q is not in the key form", fp)
}
var v offeredValue
_ = json.Unmarshal(mm.offered.values[fp], &v)
if v.Outcome != Dead || v.Node != "laptop" || v.Account != "acct-1" || v.Why == "" || v.At == "" {
t.Fatalf("offered %+v", v)
}
if u := mm.offered.until[fp]; !u.Equal(mm.now.Add(90 * 24 * time.Hour)) {
t.Fatalf("the outcome lives until %v", u)
}
}
// memLog is a log of the bus as the runtime answers it, in memory; full, it refuses an entry.
type memLog struct {
mu sync.Mutex
now func() time.Time
entries []stdio.LogEntry
full bool
}
func newMemLog(now func() time.Time) *memLog { return &memLog{now: now} }
func (l *memLog) Append(key string, value any) (uint64, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.full {
return 0, errors.New("the log is full: maximum bytes exceeded")
}
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
seq := uint64(len(l.entries) + 1)
l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.now()})
return seq, nil
}
func (l *memLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) {
l.mu.Lock()
defer l.mu.Unlock()
p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))}
for _, e := range l.entries {
if e.Sequence >= from && (keys == "" || e.Key == keys) {
if limit > 0 && len(p.Entries) == limit {
p.Next = e.Sequence
break
}
p.Entries = append(p.Entries, e)
}
}
return p, nil
}
func (l *memLog) Last(key string) (*stdio.LogEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
for i := len(l.entries) - 1; i >= 0; i-- {
if l.entries[i].Key == key {
e := l.entries[i]
return &e, nil
}
}
return nil, nil
}
func (l *memLog) Info() (*stdio.LogInfo, error) {
l.mu.Lock()
defer l.mu.Unlock()
i := &stdio.LogInfo{Entries: uint64(len(l.entries)), Last: uint64(len(l.entries))}
if len(l.entries) > 0 {
i.First = 1
}
return i, nil
}
func (l *memLog) Newest(keys string) ([]stdio.LogEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
last := map[string]int{}
for i, e := range l.entries {
if keys == "" || e.Key == keys {
last[e.Key] = i
}
}
out := []stdio.LogEntry{}
for i, e := range l.entries {
if j, ok := last[e.Key]; ok && j == i {
out = append(out, e)
}
}
return out, nil
}
// under is every entry of a log's subject, decoded.
func (l *memLog) under(key string) []map[string]any {
l.mu.Lock()
defer l.mu.Unlock()
var out []map[string]any
for _, e := range l.entries {
if key == "" || e.Key == key {
var v map[string]any
_ = json.Unmarshal(e.Value, &v)
v["what"] = e.Key
out = append(out, v)
}
}
return out
}
// design 39 bus-and-switch §6: a reading appends one entry under its licence's key, the reading beside the raw answer.
func TestAReadingAppendsOneUsageEntry(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
got := mm.usage.under(KeyFor(licence1))
if len(got) != 1 || len(mm.usage.entries) != 1 {
t.Fatalf("a reading appended %v", mm.usage.entries)
}
if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil {
t.Fatalf("the entry %v", got[0])
}
rows, _ := mm.store.Usage(ctx, licence1, time.Time{}, 20, false)
if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil {
t.Fatalf("usage answers %+v", rows)
}
}
// design 39 bus-and-switch §7, B20: each act appends one entry under its kind; a full log fails the act loudly.
func TestEachActAppendsOneAuditEntry(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_, _ = mm.m.Rotate(ctx, licence1, true)
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
_, _ = mm.m.Bind(ctx, "laptop", "api")
_, _ = mm.m.Release(ctx, "laptop")
for kind, n := range map[string]int{"adopted": 1, "bound": 1, "rotated": 1, "switched": 1, "released": 1} {
if got := len(mm.audit.under(kind)); got != n {
t.Errorf("%s: %d entries, want %d", kind, got, n)
}
}
for _, e := range mm.audit.under("") {
if e["at"] == nil || e["by"] == nil && e["what"] != "adopted" {
t.Errorf("the entry %v has no at or by", e)
}
}
mm.audit.full = true
if _, err := mm.m.Bind(ctx, "laptop", "api"); err == nil || !strings.Contains(err.Error(), "the audit did not take") {
t.Fatalf("a bind with a full audit log: %v", err)
}
}
@@ -0,0 +1,486 @@
package main
// The carry-over (design 39 bus-and-switch §8, §11 to §13, A3, B14): at its first start the manager reads every row of
// today's database, the module's own and read only, and writes it into its kept data on the bus and its grants into
// the vault, then the key `carried.postgres`. Until that key exists it serves its read verbs alone and does nothing
// else. With the key present it never reads the database again.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
)
// CarriedKey is the key of the state `carried` the carry-over writes at its end (§8).
const CarriedKey = "postgres"
// NotCarried is the answer of every acting verb and tool until the carry-over has ended (§11).
const NotCarried = "the carry-over has not ended"
// usageKeptFor is how far back the carry-over reads usage: the log `usage` keeps no older reading (§6, §12).
const usageKeptFor = 35 * 24 * time.Hour
// The tables of today's database, in the order the carry-over reads them (§11, §12).
var CarriedTables = []string{"licence", "binding", "lease", "offered", "usage", "audit"}
// LicenceRow is a row of the table `licence`: the licence and its grant at rest, sealed with `grant.key`.
type LicenceRow struct {
Licence
Sealed string
}
// OfferedRow is a row of the table `offered`.
type OfferedRow struct {
Fingerprint, Node, Account string
Outcome Outcome
Why string
At time.Time
}
// UsageTableRow is a row of the table `usage`: the reading and the vendor's answer whole.
type UsageTableRow struct {
Licence string
At int64 // milliseconds
Reading UsageReading
Raw map[string]any
}
// AuditRow is a row of the table `audit`.
type AuditRow struct {
At time.Time
What string
Detail map[string]any
}
// CarrySource reads the six tables of today's database, each oldest first; a table that does not exist is no rows.
type CarrySource interface {
Licences(ctx context.Context) ([]LicenceRow, error)
Bindings(ctx context.Context) ([]Binding, error)
Leases(ctx context.Context) ([]LeaseRow, error)
Offered(ctx context.Context) ([]OfferedRow, error)
Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error)
Audit(ctx context.Context) ([]AuditRow, error)
}
// LeaseRow is one row of today's table `lease`: its key (`licence:<name>` or `account:<id>`), holder and until when.
type LeaseRow struct {
Key string
Holder string
Until time.Time
}
// CarriedTable is one table in `carried.postgres`: the rows read and the entries written for them.
type CarriedTable struct {
Read int `json:"read"`
Written int `json:"written"`
}
// CarriedRecord is the value of `carried.postgres` (§8): when, and for each table the rows read and entries written.
type CarriedRecord struct {
At string `json:"at"`
Tables map[string]CarriedTable `json:"tables"`
}
// Carry is the carry-over's gate: closed until `carried.postgres` exists, and the failure of its last run.
type Carry struct {
State keptState // the state `carried`
// Source opens today's database; called only while the key is absent.
Source func(ctx context.Context) (CarrySource, error)
mu sync.Mutex
running bool
failure string
done chan struct{}
once sync.Once
}
func (c *Carry) init() {
c.mu.Lock()
if c.done == nil {
c.done = make(chan struct{})
}
c.mu.Unlock()
}
// Ended is whether the carry-over has ended: the key `carried.postgres` exists.
func (c *Carry) Ended() bool {
if c == nil {
return true // a manager built with no carry-over to wait on: the tests' own
}
c.init()
select {
case <-c.done:
return true
default:
}
if e, err := c.State.Get(CarriedKey); err == nil && e != nil && len(e.Value) > 0 {
c.end()
return true
}
return false
}
func (c *Carry) end() { c.once.Do(func() { close(c.done) }) }
// Wait is closed once the carry-over has ended.
func (c *Carry) Wait() <-chan struct{} {
c.init()
return c.done
}
// Acts answers an acting verb or tool: nothing until the carry-over has ended (§11).
func (c *Carry) Acts() error {
if c.Ended() {
return nil
}
return errors.New(NotCarried)
}
// Failure is what the last run that failed names, empty when none failed.
func (c *Carry) Failure() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.failure
}
// Run carries over every row when `carried.postgres` is absent, and says nothing else happened when it is present
// (§13). A run that fails names its table and error, and leaves the key absent, so the next is done again whole.
func (c *Carry) Run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) {
if c.Ended() {
return nil, nil
}
c.mu.Lock()
if c.running {
c.mu.Unlock()
return nil, errors.New("a carry-over is running")
}
c.running = true
c.mu.Unlock()
rec, err := c.run(ctx, m, s)
c.mu.Lock()
c.running = false
if err != nil {
c.failure = err.Error()
} else {
c.failure = ""
}
c.mu.Unlock()
if err != nil {
m.Log("the carry-over failed: %v", err)
return nil, err
}
c.end()
m.Log("carried over today's database: %v", rec.Tables)
return rec, nil
}
func (c *Carry) run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) {
src, err := c.Source(ctx)
if err != nil {
return nil, fmt.Errorf("the database cannot be opened: %w", err)
}
if closer, ok := src.(interface{ Close() }); ok {
defer closer.Close()
}
co := carryOver{m: m, s: s, src: src, now: m.Now(), tables: map[string]CarriedTable{}}
if err := co.all(ctx); err != nil {
return nil, err
}
rec := CarriedRecord{At: co.now.UTC().Format(time.RFC3339), Tables: co.tables}
detail := map[string]any{"why": "the first start of the manager on the bus", "by": Itself, "tables": rec.Tables}
if err := s.Audit(ctx, "carried", detail); err != nil {
return nil, fmt.Errorf("carried: %w", err)
}
if _, err := c.State.Put(CarriedKey, rec); err != nil {
return nil, fmt.Errorf("carried: the key %s was not written: %w", CarriedKey, err)
}
return &rec, nil
}
// carryOver is one run of the carry-over.
type carryOver struct {
m *Manager
s *BusStore
src CarrySource
now time.Time
tables map[string]CarriedTable
audit []AuditRow
}
func (co *carryOver) all(ctx context.Context) error {
// The audit is read first: a licence's adoptedFrom comes from it (B14).
audit, err := co.src.Audit(ctx)
if err != nil {
return fmt.Errorf("audit: %w", err)
}
co.audit = audit
for _, table := range CarriedTables {
var t CarriedTable
var err error
switch table {
case "licence":
t, err = co.licences(ctx)
case "binding":
t, err = co.bindings(ctx)
case "lease":
t, err = co.leases(ctx)
case "offered":
t, err = co.offered(ctx)
case "usage":
t, err = co.usage(ctx)
case "audit":
t, err = co.audits()
}
if err != nil {
return fmt.Errorf("%s: %w", table, err)
}
co.tables[table] = t
}
return nil
}
// adoptedFrom is the node the newest adoption of a licence names in the audit (B14).
func (co *carryOver) adoptedFrom(name string) string {
from := ""
for _, r := range co.audit {
if r.What != "adopted" || r.Detail["licence"] != name {
continue
}
if n, _ := r.Detail["node"].(string); n != "" {
from = n
} else if f, _ := r.Detail["from"].(string); f != "" {
from = f
}
}
return from
}
// licences writes a key of `licences` per row, its grant opened with `grant.key` and handed to the vault at
// generation 1, the grant first (§12, §15).
func (co *carryOver) licences(ctx context.Context) (CarriedTable, error) {
rows, err := co.src.Licences(ctx)
if err != nil {
return CarriedTable{}, err
}
t := CarriedTable{Read: len(rows)}
for _, r := range rows {
l := r.Licence
l.Generation, l.Usage = 0, nil
l.AdoptedFrom = co.adoptedFrom(l.Name)
if r.Sealed != "" {
if co.m.Crypt == nil {
return t, fmt.Errorf("the grant of %s cannot be opened: no grant.key", l.Name)
}
plain, err := co.m.Crypt.Open(r.Sealed)
if err != nil {
return t, fmt.Errorf("the grant of %s: %w", l.Name, err)
}
grant, access := plain, plain
if l.Kind == "subscription" {
var g FullGrant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return t, fmt.Errorf("the grant of %s is not a grant", l.Name)
}
access = accessOf(g)
}
if err := co.m.handOver(ctx, &l, grant, access); err != nil {
return t, err
}
}
if err := co.s.SaveLicence(ctx, l); err != nil {
return t, err
}
t.Written++
}
return t, nil
}
// bindings writes a key of `bindings` per row, its generation kept, why "carried over", by the manager, since the
// carry-over's time (§12, B14); a key a run before this one wrote for the same row keeps its since, so it is put again
// with the same value (§13).
func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) {
rows, err := co.src.Bindings(ctx)
if err != nil {
return CarriedTable{}, err
}
kinds := map[string]string{}
if all, err := co.s.Licences(ctx); err == nil {
for _, l := range all {
kinds[l.Name] = l.Kind
}
} else {
return CarriedTable{}, err
}
t := CarriedTable{Read: len(rows)}
for _, r := range rows {
v := BindingState{Licence: r.Licence, Kind: kinds[r.Licence], Generation: r.Generation,
Since: co.now.UTC().Format(time.RFC3339), Why: "carried over", By: Itself}
var held BindingState
ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held)
if err != nil {
return t, err
}
if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why {
v.Since = held.Since
}
if _, err := co.s.BindingsState.Put(r.Consumer, v); err != nil {
return t, err
}
// Said after its key is written, as every binding's change is (§21), so a reader's first view taken during the
// carry-over is brought up to date by the event.
from := ""
if ok && held.Licence != v.Licence {
from = held.Licence
}
co.m.changed(Binding{Consumer: r.Consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Why: v.Why, By: v.By}, from)
t.Written++
}
return t, nil
}
// leases writes a key of `leases` per lease still live at the run, with its remaining lifetime, so the manager never
// refreshes a licence the bundle before it holds a lease on, whatever the delivery's order; an expired one is counted
// read and not written (B44). Its key takes the bus's form: `licence.<licence key>` or `account.<id>` (§4).
func (co *carryOver) leases(ctx context.Context) (CarriedTable, error) {
rows, err := co.src.Leases(ctx)
if err != nil {
return CarriedTable{}, err
}
t := CarriedTable{Read: len(rows)}
for _, r := range rows {
left := r.Until.Sub(co.now)
if left <= 0 {
continue
}
key := ""
if name, ok := strings.CutPrefix(r.Key, "licence:"); ok {
key = "licence." + KeyFor(name)
} else if account, ok := strings.CutPrefix(r.Key, "account:"); ok {
key = "account." + account
} else {
co.m.Log("the lease %s names neither a licence nor an account; it is not carried", r.Key)
continue
}
v := leaseValue{Holder: r.Holder, Until: r.Until.UTC().Format(time.RFC3339)}
if _, err := co.s.LeasesState.PutTTL(key, v, left); err != nil {
return t, err
}
t.Written++
}
return t, nil
}
// offered writes a key of `offered` per row, its lifetime counted from the row's `at`; a row past its lifetime is
// gone already and written nowhere (§5, §12).
func (co *carryOver) offered(ctx context.Context) (CarriedTable, error) {
rows, err := co.src.Offered(ctx)
if err != nil {
return CarriedTable{}, err
}
t := CarriedTable{Read: len(rows)}
for _, r := range rows {
left := offeredFor - co.now.Sub(r.At)
if left <= 0 {
continue
}
v := offeredValue{Node: r.Node, Account: r.Account, Outcome: r.Outcome, Why: r.Why,
At: r.At.UTC().Format(time.RFC3339)}
if err := co.s.putOffered(r.Fingerprint, v, left); err != nil {
return t, err
}
t.Written++
}
return t, nil
}
// lastAt is the `at` of a log's last entry for a subject, zero when it has none: rows at or before it are in the log
// already (§13).
func lastAt(l keptLog, key string) (time.Time, error) {
e, err := l.Last(key)
if err != nil || e == nil {
return time.Time{}, err
}
var v struct {
At string `json:"at"`
}
if json.Unmarshal(e.Value, &v) != nil {
return time.Time{}, nil
}
at, _ := time.Parse(time.RFC3339Nano, v.At)
return at, nil
}
// usage appends one entry of the log `usage` per row of the last 35 days, oldest first, its `at` kept (§12).
func (co *carryOver) usage(ctx context.Context) (CarriedTable, error) {
rows, err := co.src.Usage(ctx, co.now.Add(-usageKeptFor))
if err != nil {
return CarriedTable{}, err
}
t := CarriedTable{Read: len(rows)}
last := map[string]time.Time{}
for _, r := range rows {
key := KeyFor(r.Licence)
before, seen := last[key]
if !seen {
if before, err = lastAt(co.s.UsageLog, key); err != nil {
return t, err
}
last[key] = before
}
at := time.UnixMilli(r.At).UTC()
if !at.After(before) {
t.Written++ // in the log already, from a run before this one
continue
}
if _, err := co.s.UsageLog.Append(key, UsageEntry{At: at.Format(time.RFC3339Nano), Reading: r.Reading,
Raw: r.Raw}); err != nil {
return t, err
}
t.Written++
}
return t, nil
}
// audits appends one entry of the log `audit` per row, oldest first, its `at` kept, under the subject its `what`
// names, `drift` for any other (§7, §12).
func (co *carryOver) audits() (CarriedTable, error) {
t := CarriedTable{Read: len(co.audit)}
last := map[string]time.Time{}
for _, r := range co.audit {
kind := "drift"
for _, k := range AuditKinds {
if k == r.What {
kind = k
}
}
before, seen := last[kind]
if !seen {
var err error
if before, err = lastAt(co.s.AuditLog, kind); err != nil {
return t, err
}
last[kind] = before
}
if !r.At.After(before) {
t.Written++ // in the log already, from a run before this one
continue
}
entry := map[string]any{}
for k, v := range r.Detail {
entry[k] = v
}
entry["at"] = r.At.UTC().Format(time.RFC3339Nano)
if kind != r.What {
entry["what"] = r.What
}
if _, err := co.s.AuditLog.Append(kind, entry); err != nil {
return t, fmt.Errorf("the audit did not take the carried %s entry: %w", r.What, err)
}
t.Written++
}
return t, nil
}
@@ -0,0 +1,314 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
// memSource is today's database as the carry-over reads it, from rows given; failAt makes one table's read fail.
type memSource struct {
licences []LicenceRow
bindings []Binding
leases []LeaseRow
offered []OfferedRow
usage []UsageTableRow
audit []AuditRow
failAt string
}
func (s *memSource) fail(table string) error {
if s.failAt == table {
return errors.New("connection reset by peer")
}
return nil
}
func (s *memSource) Licences(context.Context) ([]LicenceRow, error) {
return s.licences, s.fail("licence")
}
func (s *memSource) Bindings(context.Context) ([]Binding, error) {
return s.bindings, s.fail("binding")
}
func (s *memSource) Leases(context.Context) ([]LeaseRow, error) { return s.leases, s.fail("lease") }
func (s *memSource) Offered(context.Context) ([]OfferedRow, error) {
return s.offered, s.fail("offered")
}
func (s *memSource) Audit(context.Context) ([]AuditRow, error) { return s.audit, s.fail("audit") }
func (s *memSource) Usage(_ context.Context, since time.Time) ([]UsageTableRow, error) {
var out []UsageTableRow
for _, u := range s.usage {
if u.At >= since.UnixMilli() {
out = append(out, u)
}
}
return out, s.fail("usage")
}
// todaysRows is a copy of the shape of today's rows: a subscription and an API key, two bindings, a lease, two offers,
// three readings (one older than 35 days) and four audit rows (one of a kind the log does not name).
func todaysRows(t *testing.T, mm *miniMesh) *memSource {
t.Helper()
grant, _ := json.Marshal(FullGrant{AccessToken: "at-old", RefreshToken: "rt-old", ExpiresAt: 7})
pct := 42.0
return &memSource{
licences: []LicenceRow{
{Licence: Licence{Name: licence1, Kind: "subscription", AccountUUID: "acct-1", Email: licence1,
RefreshFingerprint: Fingerprint("rt-old"), AccessExpiresAt: 7, AdoptedAt: 1}, Sealed: sealAtRest(t, mm.m.Crypt, string(grant))},
{Licence: Licence{Name: "api", Kind: "api-key", AdoptedAt: 2}, Sealed: sealAtRest(t, mm.m.Crypt, "sk-ant-api03-x")},
},
bindings: []Binding{{Consumer: "laptop", Licence: licence1, Generation: 1013}, {Consumer: "server", Licence: "api", Generation: 1016}},
leases: []LeaseRow{{Key: "licence:x", Holder: "novox:1", Until: t0.Add(-time.Minute)}},
offered: []OfferedRow{{Fingerprint: "sha256:aa", Node: "laptop", Account: "acct-1", Outcome: Adopted, Why: "refreshed", At: t0.Add(-24 * time.Hour)},
{Fingerprint: "sha256:bb", Node: "server", Outcome: Dead, Why: "400", At: t0.Add(-48 * time.Hour)}},
usage: []UsageTableRow{
{Licence: licence1, At: t0.Add(-40 * 24 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}},
{Licence: licence1, At: t0.Add(-2 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}, Raw: map[string]any{"five_hour": 1.0}},
{Licence: licence1, At: t0.Add(-1 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}},
},
audit: []AuditRow{
{At: t0.Add(-72 * time.Hour), What: "adopted", Detail: map[string]any{"licence": licence1, "node": "laptop"}},
{At: t0.Add(-71 * time.Hour), What: "bound", Detail: map[string]any{"consumer": "laptop"}},
{At: t0.Add(-70 * time.Hour), What: "switched", Detail: map[string]any{"consumer": "server"}},
{At: t0.Add(-69 * time.Hour), What: "grant-moved", Detail: map[string]any{"licence": "api"}},
},
}
}
func carryFor(mm *miniMesh, carried *memState, src CarrySource) *Carry {
return &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { return src, nil }}
}
func readCarried(t *testing.T, carried *memState) CarriedRecord {
t.Helper()
var rec CarriedRecord
e, _ := carried.Get(CarriedKey)
if e == nil {
t.Fatal("carried.postgres is not written")
}
_ = json.Unmarshal(e.Value, &rec)
return rec
}
// design 39 bus-and-switch §11/AC1, §12/AC1: every row is carried over and the key written; the counts in
// carried.postgres equal the rows read for every table but lease, and the vault answers each licence's grant.
func TestEveryRowIsCarriedOver(t *testing.T) {
mm := newMesh(t)
carried := newMemState(func() time.Time { return mm.now })
src := todaysRows(t, mm)
c := carryFor(mm, carried, src)
if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil {
t.Fatal(err)
}
rec := readCarried(t, carried)
want := map[string]int{"licence": 2, "binding": 2, "lease": 1, "offered": 2, "usage": 2, "audit": 4}
for table, n := range want {
got := rec.Tables[table]
if got.Read != n || (table != "lease" && got.Written != got.Read) {
t.Errorf("%s: read %d written %d, want %d read and as many written", table, got.Read, got.Written, n)
}
}
if g, err := mm.grants.Get(context.Background(), KeyFor(licence1), PartGrant); err != nil || !strings.Contains(g, "rt-old") {
t.Fatalf("the vault answers the grant %q, %v", g, err)
}
if a, _ := mm.grants.Get(context.Background(), KeyFor(licence1), PartAccess); strings.Contains(a, "rt-old") || !strings.Contains(a, "at-old") {
t.Fatalf("the access secret is %q", a)
}
if a, _ := mm.grants.Get(context.Background(), KeyFor("api"), PartAccess); a != "sk-ant-api03-x" {
t.Fatalf("the API key's access is %q", a)
}
l, _ := mm.store.Licence(context.Background(), licence1)
if l == nil || l.Generation != 1 || l.AdoptedFrom != "laptop" || l.AccountUUID != "acct-1" || l.AccessExpiresAt != 7 {
t.Fatalf("the licence is %+v", l)
}
if b := mm.bound("laptop"); b.Generation != 1013 || b.Why != "carried over" || b.By != Itself || b.Kind != "subscription" || b.Since == "" {
t.Fatalf("laptop's binding %+v", b)
}
if u := mm.offered.until[OfferedKey("sha256:aa")]; !u.Equal(t0.Add(-24 * time.Hour).Add(offeredFor)) {
t.Fatalf("the offer lives until %v", u)
}
if got := mm.audit.under("drift"); len(got) != 1 || got[0]["licence"] != "api" {
t.Fatalf("drift holds %v", got)
}
if got := mm.audit.under("adopted"); len(got) != 1 || got[0]["at"] != t0.Add(-72*time.Hour).Format(time.RFC3339Nano) {
t.Fatalf("adopted holds %v", got)
}
if got := mm.usage.under(KeyFor(licence1)); len(got) != 2 || got[0]["raw"] == nil {
t.Fatalf("usage holds %v", got)
}
if len(mm.audit.under("carried")) != 1 || !c.Ended() {
t.Fatal("the carry-over did not say it carried, or did not end")
}
// §21: each carried binding is said as binding.changed after its key is written.
said := map[string]bool{}
for _, e := range mm.changedSince(0) {
if e["why"] == "carried over" && e["key"] == KeyFor(fmt.Sprint(e["licence"])) {
said[fmt.Sprint(e["consumer"])] = true
}
}
if !said["laptop"] || !said["server"] || len(mm.changedSince(0)) != 2 {
t.Fatalf("the carry-over said %v", mm.changedSince(0))
}
}
// design 39 bus-and-switch §13/AC1: a second start, with carried.postgres present, never opens the database and
// writes nothing.
func TestASecondStartCarriesNothing(t *testing.T) {
mm := newMesh(t)
carried := newMemState(func() time.Time { return mm.now })
if _, err := carryFor(mm, carried, todaysRows(t, mm)).Run(context.Background(), mm.m, mm.store); err != nil {
t.Fatal(err)
}
writes := carried.writes + mm.licences.writes + mm.bindings.writes + mm.offered.writes
entries := len(mm.audit.entries) + len(mm.usage.entries)
puts := len(mm.grants.puts)
opened := false
again := &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { opened = true; return nil, errors.New("no") }}
if rec, err := again.Run(context.Background(), mm.m, mm.store); rec != nil || err != nil || opened {
t.Fatalf("a second start answered %v %v, opened the database: %v", rec, err, opened)
}
if carried.writes+mm.licences.writes+mm.bindings.writes+mm.offered.writes != writes ||
len(mm.audit.entries)+len(mm.usage.entries) != entries || len(mm.grants.puts) != puts {
t.Fatal("a second start wrote")
}
}
// design 39 bus-and-switch §13/AC2: a carry-over stopped after bindings leaves no key, names its table, and a start
// again carries over whole: every count equals the rows read, and no log entry is appended twice.
func TestAStoppedCarryOverIsDoneAgainWhole(t *testing.T) {
mm := newMesh(t)
carried := newMemState(func() time.Time { return mm.now })
src := todaysRows(t, mm)
src.failAt = "offered"
c := carryFor(mm, carried, src)
if _, err := c.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(c.Failure(), "offered") {
t.Fatalf("a stopped carry-over answered %v, its failure %q", err, c.Failure())
}
if e, _ := carried.Get(CarriedKey); e != nil || c.Ended() {
t.Fatal("a stopped carry-over wrote its key")
}
src.failAt = ""
since := mm.bound("laptop").Since
mm.now = mm.now.Add(time.Minute)
if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil {
t.Fatal(err)
}
for table, got := range readCarried(t, carried).Tables {
if table != "lease" && got.Written != got.Read {
t.Errorf("%s: read %d written %d", table, got.Read, got.Written)
}
}
if b := mm.bound("laptop"); b.Since != since {
t.Fatalf("the binding put again changed its since from %s to %s", since, b.Since)
}
if n := len(mm.audit.under("")); n != 5 {
t.Fatalf("the audit holds %d entries, want the four rows and one carried", n)
}
// the run stopped before the logs; a second pass over a log already written appends nothing.
co := carryOver{m: mm.m, s: mm.store, src: src, now: mm.now, tables: map[string]CarriedTable{}, audit: src.audit}
if _, err := co.usage(context.Background()); err != nil {
t.Fatal(err)
}
if got, err := co.audits(); err != nil || got.Written != 4 || len(mm.audit.under("")) != 5 || len(mm.usage.under("")) != 2 {
t.Fatalf("a log written again appended: %+v %v, %d usage entries", got, err, len(mm.usage.under("")))
}
}
// design 39 bus-and-switch §11/AC2: with the database unreachable the carry-over fails naming it, every acting verb
// answers "the carry-over has not ended", a read verb is served, and no vendor call is made.
func TestAnUnreachableDatabaseActsNotAndCallsNoVendor(t *testing.T) {
mm := newMesh(t)
carried := newMemState(func() time.Time { return mm.now })
mm.m.Carry = &Carry{State: carried, Source: func(context.Context) (CarrySource, error) {
return nil, errors.New("dial tcp 192.0.2.1:5432: connect: connection refused")
}}
if _, err := mm.m.Carry.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(mm.m.Carry.Failure(), "connection refused") {
t.Fatalf("the run answered %v, its failure %q", err, mm.m.Carry.Failure())
}
built = mm.m
t.Cleanup(func() { built = nil })
served := map[string]stdTool{}
for _, tool := range tools() {
served[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
}
for _, name := range []string{"bind", "switch", "release", "refresh", "adopt"} {
if _, err := served[name](map[string]any{"consumer": "laptop", "licence": licence1, "name": "api", "file": "/none"}); err == nil || err.Error() != NotCarried {
t.Errorf("%s answered %v", name, err)
}
}
for _, name := range []string{"licences", "bindings", "usage", "public-key"} {
if _, err := served[name](map[string]any{}); err != nil {
t.Errorf("the read verb %s answered %v", name, err)
}
}
if len(mm.vendor.exchanged) != 0 {
t.Fatalf("the vendor was called: %v", mm.vendor.exchanged)
}
}
type stdTool func(map[string]any) (any, error)
// design 39 bus-and-switch §11/AC3: a table that does not exist (a fresh install) is read as no rows; any other
// failure of the database is one.
func TestAMissingTableIsNoRows(t *testing.T) {
if !noTable(&pgconn.PgError{Code: "42P01", Message: `relation "offered" does not exist`}) {
t.Fatal("a missing table is a failure")
}
for _, err := range []error{&pgconn.PgError{Code: "28P01"}, errors.New("connection refused"), nil} {
if noTable(err) {
t.Fatalf("%v is read as a missing table", err)
}
}
}
// design 39 bus-and-switch §1/AC3: no code of the manager writes to its database.
func TestNoWriteToTheDatabase(t *testing.T) {
write := regexp.MustCompile(`(?i)\b(insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+(table|sequence|index)|drop\s+(table|sequence|index)|alter\s+table|truncate\s|setval\s*\(|nextval\s*\()`)
files, _ := os.ReadDir(".")
for _, f := range files {
if !strings.HasSuffix(f.Name(), ".go") || strings.HasSuffix(f.Name(), "_test.go") {
continue
}
raw, err := os.ReadFile(f.Name())
if err != nil {
t.Fatal(err)
}
if w := write.Find(raw); w != nil {
t.Errorf("%s writes to the database: %q", f.Name(), w)
}
}
}
// design 39 bus-and-switch §12, B44: a lease still live at the run is carried with its remaining lifetime, and a
// refresh waits for it to end; an expired one is counted read and not written.
func TestALiveLeaseIsCarriedAndBlocksARefreshUntilItEnds(t *testing.T) {
mm := newMesh(t)
carried := newMemState(func() time.Time { return mm.now })
src := todaysRows(t, mm)
src.leases = []LeaseRow{{Key: "licence:" + licence1, Holder: "the old bundle", Until: mm.now.Add(90 * time.Second)},
{Key: "account:acct-1", Holder: "the old bundle", Until: mm.now.Add(-time.Second)}}
if _, err := carryFor(mm, carried, src).Run(context.Background(), mm.m, mm.store); err != nil {
t.Fatal(err)
}
if got := readCarried(t, carried).Tables["lease"]; got.Read != 2 || got.Written != 1 {
t.Fatalf("lease: read %d written %d, want 2 read and 1 written", got.Read, got.Written)
}
if e, _ := mm.leases.Get("account.acct-1"); e != nil {
t.Fatalf("an expired lease was carried: %s", e.Value)
}
r, err := mm.m.Rotate(context.Background(), licence1, true)
if err != nil || r["refreshed"] != false || r["reason"] != "another run holds its lease" {
t.Fatalf("a refresh under the carried lease answered %v, %v", r, err)
}
mm.now = mm.now.Add(91 * time.Second)
if r, err := mm.m.Rotate(context.Background(), licence1, true); err != nil || r["reason"] == "another run holds its lease" {
t.Fatalf("once the carried lease ended the refresh answered %v, %v", r, err)
}
}
@@ -0,0 +1,328 @@
package main
// The manager's conditions (design 39 bus-and-switch §9, §11, §29 to §31, §33, B17, B37 to B39): each said on the
// operator channel as any module says one (novox/hq ADR 0437 §9), the router alone deciding whether and where the
// operator is told, and each it said raised kept in its state `conditions`, written before it is said and deleted when
// it is said cleared. A raise or a clear is said again only when what it says changes (ADR 0289 §1); at its start the
// manager judges every kind anew, says raised again each it still holds and cleared each key it no longer holds.
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Publisher is the name the manager says its conditions under, and the id of their subject (B17).
const Publisher = "claude-licence-manager"
// CarryOverFailed is the condition of a carry-over that failed (§11).
const CarryOverFailed = "carry-over-failed"
// HeadlineLength is the operator channel's limit on a headline; a longer one is cut, ending in "…" (B39).
const HeadlineLength = 60
// KeptCondition is the value of a key of `conditions` (§9): the condition said raised, and since when.
type KeptCondition struct {
Body oc.Condition `json:"body"`
Since string `json:"since"`
}
// SayCondition publishes a condition on the operator channel's condition accept, under the manager's name, in the form
// its client encodes (ADR 0437 §9).
func SayCondition(c oc.Condition) error {
subject, err := occ.ConditionSubject(Publisher)
if err != nil {
return err
}
raw, err := occ.EncodeCondition(c)
if err != nil {
return err
}
return stdio.SeatPublish(subject, json.RawMessage(raw), "")
}
// condition is a raised condition of the manager: its key, severity, words and the line said when it is over. An
// empty needs is "Nothing for you to do."; the explanation is opened by its verdict here.
func condition(key, severity, headline, summary, needs, explanation, resolved string) oc.Condition {
verdict := occ.NothingToDo
if needs != "" {
verdict = occ.NeedsYou + needs + "."
}
if explanation != "" {
verdict += " " + explanation
}
return oc.Condition{Key: key, Severity: severity, State: occ.StateRaised,
Subject: oc.ConditionSubject{Scope: "module", ID: Publisher},
Headline: cut(headline), Summary: summary, Needs: needs, Explanation: verdict, Resolved: resolved}
}
// cut keeps a headline within the operator channel's limit, ending a longer one in "…" (B39).
func cut(headline string) string {
r := []rune(headline)
if len(r) <= HeadlineLength {
return headline
}
return string(r[:HeadlineLength-1]) + "…"
}
// short is a licence's short name for a headline: its address before the "@", or its name (B39).
func short(name string) string {
if at := strings.Index(name, "@"); at > 0 {
return name[:at]
}
return name
}
// machines is a count of consumers in a headline (B39).
func machines(n int) string {
if n == 1 {
return "1 machine"
}
return fmt.Sprintf("%d machines", n)
}
// Judge is every condition the manager holds now, judged anew from its own state and readings, by key.
func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) {
held := map[string]oc.Condition{}
if m.Carry != nil && !m.Carry.Ended() {
if why := m.Carry.Failure(); why != "" {
held[CarryOverFailed] = condition(CarryOverFailed, occ.SeverityUrgent,
"The licence manager could not carry over its licences",
"the carry-over of today's database failed: "+why,
"the licence manager could not carry over its licences; no consumer is moved and no token is refreshed until it does",
"It failed: "+why+".", "The licence manager carried over its licences")
}
}
s := m.settings()
for _, o := range s.OutOfBound {
bound := fmt.Sprintf("at least %g", o.Min)
if o.HasMax {
bound = fmt.Sprintf("between %g and %g", o.Min, o.Max)
}
key := "settings." + o.Setting + ".invalid"
held[key] = condition(key, occ.SeverityWarning, o.Setting+" is outside its bound",
fmt.Sprintf("%s is %g, outside its bound; its default %g is used", o.Setting, o.Value, o.Default),
fmt.Sprintf("set %s %s", o.Setting, bound),
fmt.Sprintf("%s is set to %g; the licence manager uses its default %g until it is fixed.", o.Setting,
o.Value, o.Default), o.Setting+" is within its bound again")
}
for _, p := range s.Invalid() {
key := "settings." + p.Setting + ".invalid"
if _, ok := held[key]; ok {
continue
}
held[key] = condition(key, occ.SeverityWarning, p.Setting+" is above switch_at_pct",
fmt.Sprintf("%s is %g, above switch_at_pct %g; no consumer is moved", p.Setting, p.Value, p.SwitchAt),
fmt.Sprintf("set %s at or below switch_at_pct (%g)", p.Setting, p.SwitchAt),
fmt.Sprintf("%s is %g, above switch_at_pct %g; the licence manager moves no consumer until it is fixed.",
p.Setting, p.Value, p.SwitchAt), p.Setting+" is at or below switch_at_pct again")
}
licences, err := m.Store.Licences(ctx)
if err != nil {
return nil, err
}
bindings, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
for _, l := range licences {
for key, c := range m.judgeLicence(l, licences, bindings, s) {
held[key] = c
}
}
return held, nil
}
// judgeLicence is what licence l holds of no-room (§29), near-limit (§30) and usage-unread (§31).
func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding, s Settings) map[string]oc.Condition {
held := map[string]oc.Condition{}
key := "licence." + KeyFor(l.Name) + "."
consumers := consumersOf(l.Name, bindings)
list := strings.Join(consumers, ", ")
if r := m.RuleFor(l, licences, bindings, s); r.NoTarget {
o := r.Over
var rooms []string
for _, other := range licences {
if other.Name != l.Name {
rooms = append(rooms, other.Name+" "+m.roomWords(other, s))
}
}
sort.Strings(rooms)
others := "no other licence is held"
if len(rooms) > 0 {
others = strings.Join(rooms, "; ")
}
held[key+"no-room"] = condition(key+"no-room", occ.SeverityUrgent,
"No licence has room for "+machines(len(consumers)),
fmt.Sprintf("no licence has room for %s, bound to %s at %g %% of its %s window", list, l.Name, o.Pct, o.Window),
fmt.Sprintf("add a licence, or wait for %s's %s window to reset at %s", l.Name, o.Window, o.ResetsAt),
fmt.Sprintf("%s is at %g %% of its %s window, with %s bound to it; %s.", l.Name, o.Pct, o.Window, list, others),
list+" have a licence with room again")
}
if window, pct, resetsAt, ok := windowAt(l.Usage, s.WarnAt); ok && len(consumers) > 0 {
needs, plan := "", ""
if to, _ := m.targetFor(l, licences, s); to != "" {
plan = fmt.Sprintf("%s move to %s at %g %%. ", list, to, s.SwitchAt)
} else {
needs = "no other licence has room"
}
held[key+"near-limit"] = condition(key+"near-limit", occ.SeverityWarning,
fmt.Sprintf("%s at %g%% %s", short(l.Name), pct, window),
fmt.Sprintf("%s at %g %% of its %s window", l.Name, pct, window), needs,
fmt.Sprintf("%s%s is at %g %% of its %s window, which resets at %s; %s bound to it.", plan, l.Name, pct,
window, resetsAt, list), fmt.Sprintf("%s is below %g %% again", l.Name, s.WarnAt))
}
if since, stale := m.unread(l, s); stale {
needs := ""
if len(consumers) > 0 {
needs = list + " are not moved off it while its usage is unknown"
}
why := l.Usage.readError()
held[key+"usage-unread"] = condition(key+"usage-unread", occ.SeverityWarning,
short(l.Name)+"'s usage cannot be read",
fmt.Sprintf("the usage of %s cannot be read: %s", l.Name, why), needs,
fmt.Sprintf("%s, older than %g minutes; the vendor answers: %s.", since, s.UsageStale.Minutes(), why),
l.Name+"'s usage is read again")
}
return held
}
// readError is the vendor's reason a reading failed, or that none was given.
func (u *UsageReading) readError() string {
if u == nil || u.ReadError == "" {
return "no reason given"
}
return u.ReadError
}
// unread is whether subscription l's latest successful reading is older than usage_stale_minutes (§31), or, never
// read successfully, its adoption is (B38); and the words naming since when.
func (m *Manager) unread(l Licence, s Settings) (string, bool) {
if l.Kind != "subscription" {
return "", false
}
if l.Usage != nil && l.Usage.ReadAt != "" {
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
if err != nil {
return "its latest successful reading has no time", true
}
return "its latest successful reading was at " + l.Usage.ReadAt, m.Now().Sub(read) > s.UsageStale
}
if l.AdoptedAt == 0 {
return "", false
}
adopted := time.UnixMilli(l.AdoptedAt)
return "no reading has succeeded since its adoption at " + adopted.UTC().Format(time.RFC3339),
m.Now().Sub(adopted) > s.UsageStale
}
// roomWords is another licence's room for the condition no-room, or why it is not known or not enough (§29).
func (m *Manager) roomWords(o Licence, s Settings) string {
room, known := Room(o, m.Now(), s)
switch {
case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow):
return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow)
case known:
return fmt.Sprintf("has %g %% room", room)
case o.Kind != "subscription":
return "is an API key, whose room is never known"
case o.Usage == nil || o.Usage.ReadAt == "":
return "has no reading"
case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil:
return "has no session or weekly window in its reading"
case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()):
return "does not refresh"
}
return fmt.Sprintf("was not read within %g minutes", s.UsageStale.Minutes())
}
// SayConditions judges every kind anew and says what changed: each condition held whose words differ from those kept
// is written in `conditions`, then said raised; each kept key no longer held is said cleared, then deleted. With
// again (at the start, and by licences_resync), each held is said raised again whether or not it changed.
func (m *Manager) SayConditions(ctx context.Context, again bool) error {
if m.Conditions == nil || m.Say == nil {
return nil
}
m.conditionsMu.Lock()
defer m.conditionsMu.Unlock()
held, err := m.Judge(ctx)
if err != nil {
return err
}
keys, err := m.Conditions.Keys()
if err != nil {
return err
}
var errs []error
for _, key := range keys {
if _, ok := held[key]; ok {
continue
}
var kept KeptCondition
e, err := m.Conditions.Get(key)
if err != nil {
errs = append(errs, err)
continue
}
if e != nil && len(e.Value) > 0 {
_ = json.Unmarshal(e.Value, &kept)
}
cleared := kept.Body
cleared.Key, cleared.State = key, occ.StateCleared
cleared.Subject = oc.ConditionSubject{Scope: "module", ID: Publisher}
if err := m.Say(cleared); err != nil {
errs = append(errs, fmt.Errorf("the clearing of %s could not be said: %w", key, err))
continue
}
if err := m.Conditions.Delete(key); err != nil {
errs = append(errs, err)
}
}
var order []string
for key := range held {
order = append(order, key)
}
sort.Strings(order)
for _, key := range order {
c := held[key]
kept := KeptCondition{Body: c, Since: m.Now().UTC().Format(time.RFC3339)}
if e, err := m.Conditions.Get(key); err == nil && e != nil && len(e.Value) > 0 {
var was KeptCondition
if json.Unmarshal(e.Value, &was) == nil {
if !again && sameCondition(was.Body, c) {
continue
}
if was.Since != "" {
kept.Since = was.Since
}
}
}
if _, err := m.Conditions.Put(key, kept); err != nil {
errs = append(errs, err)
continue
}
if err := m.Say(c); err != nil {
// Not said: the key goes again, so the next judgement says it.
_ = m.Conditions.Delete(key)
errs = append(errs, fmt.Errorf("%s could not be said: %w", key, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("conditions: %v", errs)
}
return nil
}
// sameCondition is whether two raised conditions say the same.
func sameCondition(a, b oc.Condition) bool {
ra, _ := json.Marshal(a)
rb, _ := json.Marshal(b)
return string(ra) == string(rb)
}
@@ -0,0 +1,252 @@
package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// saying gives mm's manager a state `conditions` and records each condition it says, in the form the operator
// channel's client encodes, refusing what it refuses.
func saying(mm *miniMesh) (*memState, *[]oc.Condition) {
kept := newMemState(func() time.Time { return mm.now })
said := &[]oc.Condition{}
mm.m.Conditions = kept
mm.m.Say = func(c oc.Condition) error {
if _, err := occ.EncodeCondition(c); err != nil {
return err
}
if _, err := occ.ConditionSubject(Publisher); err != nil {
return err
}
*said = append(*said, c)
return nil
}
return kept, said
}
// of is what was said of key, as "state" per saying.
func of(said []oc.Condition, key string) []string {
var out []string
for _, c := range said {
if c.Key == key {
out = append(out, c.State)
}
}
return out
}
// design 39 bus-and-switch §9/AC1, §11, §33, B17, B37: each kind of condition the manager holds of itself is written in
// `conditions` with its body and since, said once with its key, severity, scope and id, its words opened by their
// verdict, and said cleared, its key deleted, once it no longer holds.
func TestAConditionIsKeptThenSaidOnce(t *testing.T) {
ctx := context.Background()
file := filepath.Join(t.TempDir(), "settings.json")
for _, c := range []struct {
name, key, severity string
raise, fix func(mm *miniMesh)
}{
{"an invalid pair", "settings.room_below_pct.invalid", occ.SeverityWarning,
func(mm *miniMesh) { mm.m.Settings.RoomBelow = 100 }, func(mm *miniMesh) { mm.m.Settings.RoomBelow = 90 }},
{"a setting outside its bound", "settings.switch_at_pct.invalid", occ.SeverityWarning,
func(mm *miniMesh) {
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 150}`), 0o600)
mm.m.Settings = SettingsFrom(file)
},
func(mm *miniMesh) {
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 98}`), 0o600)
mm.m.Settings = SettingsFrom(file)
}},
{"a carry-over that failed", CarryOverFailed, occ.SeverityUrgent,
func(mm *miniMesh) {
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }),
Source: func(context.Context) (CarrySource, error) { return nil, errors.New("connection refused") }}
_, _ = mm.m.Carry.Run(ctx, mm.m, mm.store)
},
func(mm *miniMesh) {
mm.m.Carry.Source = func(context.Context) (CarrySource, error) { return &memSource{}, nil }
_, _ = mm.m.Carry.Run(ctx, mm.m, mm.store)
}},
} {
mm := newMesh(t)
kept, said := saying(mm)
c.raise(mm)
for range 2 {
if err := mm.m.SayConditions(ctx, false); err != nil {
t.Fatalf("%s: %v", c.name, err)
}
}
if got := of(*said, c.key); strings.Join(got, ",") != "raised" {
t.Fatalf("%s: said %v of %s, all said %+v", c.name, got, c.key, *said)
}
s := (*said)[0]
if s.Severity != c.severity || s.Subject.Scope != "module" || s.Subject.ID != Publisher || s.Headline == "" ||
s.Summary == "" || s.Resolved == "" || !strings.HasPrefix(s.Explanation, occ.NeedsYou+s.Needs) || s.Needs == "" {
t.Fatalf("%s: said %+v", c.name, s)
}
var k KeptCondition
if e, _ := kept.Get(c.key); e == nil || json.Unmarshal(e.Value, &k) != nil || k.Body.Key != c.key || k.Since == "" {
t.Fatalf("%s: kept %+v", c.name, k)
}
c.fix(mm)
_ = mm.m.SayConditions(ctx, false)
if got := of(*said, c.key); strings.Join(got, ",") != "raised,cleared" {
t.Fatalf("%s: said %v", c.name, got)
}
if e, _ := kept.Get(c.key); e != nil {
t.Fatalf("%s: the key is kept after its clearing", c.name)
}
}
}
// design 39 bus-and-switch §9/AC2: at its start the manager says cleared each key of `conditions` it no longer holds,
// and says raised again each it still holds, unchanged or not.
func TestAStartClearsWhatItNoLongerHolds(t *testing.T) {
ctx := context.Background()
mm := newMesh(t)
kept, said := saying(mm)
gone := condition("licence.gone.no-room", occ.SeverityUrgent, "No licence has room for 1 machine", "s", "add one",
"", "x has a licence with room again")
mm.m.Settings.WarnAt = 100
_ = mm.m.SayConditions(ctx, false)
_, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"})
*said = nil
if err := mm.m.SayConditions(ctx, true); err != nil {
t.Fatal(err)
}
if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" {
t.Fatalf("a start said %+v", *said)
}
if (*said)[0].Resolved != gone.Resolved {
t.Fatalf("the clearing carries %+v", (*said)[0])
}
if e, _ := kept.Get(gone.Key); e != nil {
t.Fatal("a key no longer held is kept")
}
}
// design 39 bus-and-switch §29/AC1, B39: with every licence at its threshold, no-room is said once, its headline a
// count of machines, its words naming the licence, the window's reset and every other licence's room; a reading with
// room clears it.
func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
ctx := context.Background()
mm := fourOnA(t)
_, said := saying(mm)
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 20)))
_ = mm.store.SaveLicence(ctx, sub("c", read(40, 99)))
for range 2 {
_ = mm.m.SayConditions(ctx, false)
}
key := "licence.a.no-room"
if strings.Join(of(*said, key), ",") != "raised" {
t.Fatalf("said %+v", *said)
}
var c oc.Condition
for _, s := range *said {
if s.Key == key {
c = s
}
}
if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" ||
c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" ||
!strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") ||
!strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") ||
c.Resolved != "ace, g14, laptop, server have a licence with room again" {
t.Fatalf("no-room %+v", c)
}
_ = mm.store.SaveLicence(ctx, sub("b", read(10, 20)))
_ = mm.m.SayConditions(ctx, false)
if strings.Join(of(*said, key), ",") != "raised,cleared" {
t.Fatalf("said %v after a reading with room", of(*said, key))
}
}
// design 39 bus-and-switch §30/AC1, B39: a rising series of readings says near-limit once at the crossing and again only
// when its words change; with no other licence having room it needs the operator; a reading after the reset clears it.
func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
ctx := context.Background()
mm := fourOnA(t)
_, said := saying(mm)
key := "licence.a.near-limit"
var last oc.Condition
for _, w := range []float64{80, 89, 91, 91, 95} {
_ = mm.store.SaveLicence(ctx, sub("a", read(10, w)))
_ = mm.m.SayConditions(ctx, false)
for _, s := range *said {
if s.Key == key {
last = s
}
}
}
if strings.Join(of(*said, key), ",") != "raised,raised" || last.Headline != "a at 95% weekly" || last.Needs != "" ||
!strings.HasPrefix(last.Explanation, occ.NothingToDo+" ace, g14, laptop, server move to b at 99 %.") ||
!strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") {
t.Fatalf("said %v, last %+v", of(*said, key), last)
}
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 0)))
_ = mm.store.SaveLicence(ctx, sub("c", read(95, 0)))
_ = mm.m.SayConditions(ctx, false)
if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" {
t.Fatalf("with no target said %+v", c)
}
_ = mm.store.SaveLicence(ctx, sub("a", read(3, 1)))
_ = mm.m.SayConditions(ctx, false)
if got := of(*said, key); got[len(got)-1] != "cleared" || len(got) != 4 {
t.Fatalf("after the reset said %v", got)
}
}
// design 39 bus-and-switch §31/AC1, B34, B38: three failed readings in a row, the latest success then older than
// usage_stale_minutes, say usage-unread naming the vendor's reason, needing the operator for the consumers bound to it;
// one success clears it. A subscription never read successfully is said once its adoption is that old.
func TestUsageUnreadIsSaidAndCleared(t *testing.T) {
ctx := context.Background()
mm := newMesh(t)
_, said := saying(mm)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
key := "licence." + KeyFor(licence1) + ".usage-unread"
mm.vendor.usageErr = errors.New("429 Too Many Requests: rate limited")
for i := 1; i <= 3; i++ {
mm.now = t0.Add(time.Duration(i) * 6 * time.Minute)
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
if got := len(of(*said, key)); (i < 3 && got != 0) || (i == 3 && got != 1) {
t.Fatalf("after %d failed readings said %v", i, of(*said, key))
}
}
c := (*said)[len(*said)-1]
if c.Severity != occ.SeverityWarning || c.Needs != "laptop are not moved off it while its usage is unknown" ||
!strings.Contains(c.Explanation, "the vendor answers: 429 Too Many Requests: rate limited") {
t.Fatalf("usage-unread %+v", c)
}
mm.vendor.usageErr = nil
_ = mm.m.ReadUsage(ctx)
if strings.Join(of(*said, key), ",") != "raised,cleared" {
t.Fatalf("after a success said %v", of(*said, key))
}
// B38: never read successfully since its adoption.
never := Licence{Name: "new@example.org", Kind: "subscription", AdoptedAt: mm.now.UnixMilli()}
_ = mm.store.SaveLicence(ctx, never)
_ = mm.m.SayConditions(ctx, false)
newKey := "licence." + KeyFor(never.Name) + ".usage-unread"
if len(of(*said, newKey)) != 0 {
t.Fatal("said at its adoption")
}
mm.now = mm.now.Add(16 * time.Minute)
_ = mm.m.SayConditions(ctx, false)
if strings.Join(of(*said, newKey), ",") != "raised" {
t.Fatalf("said %v of a licence never read", of(*said, newKey))
}
}
@@ -5,7 +5,6 @@ package main
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
@@ -13,7 +12,7 @@ import (
"strings"
)
// Crypt seals and opens what the store keeps.
// Crypt opens what today's database keeps at rest.
type Crypt struct{ key []byte }
// NewCrypt is the store's cipher from the vault's secret.
@@ -35,16 +34,8 @@ func CryptFromFile(path string) (*Crypt, error) {
return NewCrypt(string(raw))
}
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
func (c *Crypt) Seal(plaintext string) string {
gcm, _ := newGCM(c.key)
iv := make([]byte, 12)
_, _ = rand.Read(iv)
b64 := base64.StdEncoding.EncodeToString
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
}
// Open decrypts what Seal made with the same key.
// Open decrypts a grant today's database keeps at rest, `v1.<iv>.<ciphertext and tag>`: the carry-over's alone, since
// nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15).
func (c *Crypt) Open(sealed string) (string, error) {
parts := strings.Split(sealed, ".")
if len(parts) != 3 || parts[0] != "v1" {
@@ -0,0 +1,68 @@
package main
// What every node holds, heard as events (design 39 bus-and-switch §23, §46, B12): the agent module says
// `holdings-changed` with its holdings report whole, at its start, on every change and when asked; the manager says
// `holdings.asked` at every start, after its carry-over, and on its resync, and takes each report it hears as that
// node's latest.
import (
"encoding/json"
"fmt"
"sort"
"sync"
"time"
)
// HoldingsChanged is the agent module's event saying what one node holds, which the manager consumes.
const HoldingsChanged = "claude-code.holdings-changed"
// HoldingsAsked is the manager's event asking every node to say what it holds again.
const HoldingsAsked = "holdings.asked"
// Heard is what every node last said it holds, by node.
type Heard struct {
mu sync.Mutex
reports map[string]Holdings
}
// Take keeps one `holdings-changed` as its node's latest report.
func (h *Heard) Take(body []byte) (Holdings, error) {
var r Holdings
if err := json.Unmarshal(body, &r); err != nil {
return r, fmt.Errorf("%s is not a holdings report: %w", HoldingsChanged, err)
}
if r.Node == "" {
return r, fmt.Errorf("%s names no node", HoldingsChanged)
}
h.mu.Lock()
defer h.mu.Unlock()
if h.reports == nil {
h.reports = map[string]Holdings{}
}
h.reports[r.Node] = r
return r, nil
}
// All is every node's latest report, by node's name.
func (h *Heard) All() []Holdings {
h.mu.Lock()
all := make([]Holdings, 0, len(h.reports))
for _, r := range h.reports {
all = append(all, r)
}
h.mu.Unlock()
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
return all
}
// Of is the generation node last reported applying.
func (h *Heard) Of(node string) int64 {
h.mu.Lock()
defer h.mu.Unlock()
return h.reports[node].Generation
}
// AskHoldings says `holdings.asked`, its time and who asks (§23).
func (m *Manager) AskHoldings(by string) error {
return m.Emit(HoldingsAsked, map[string]any{"at": m.Now().UTC().Format(time.RFC3339Nano), "by": by})
}
@@ -0,0 +1,41 @@
package main
// A licence's key form (design 39 bus-and-switch §10, B6): the name every verb and tool takes and answers, written
// in the characters a key of the bus takes. The key is the store's alone.
import (
"context"
"strings"
)
// KeyFor is a licence's key: its name in lower case, each `@` written as `_at_`, and every other character
// outside letters, digits, `-` and `.` written as `_`.
func KeyFor(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
switch {
case r == '@':
b.WriteString("_at_")
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}
// keyTaken names the licence other than name whose key is name's, or "" when none is.
func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) {
all, err := m.Store.Licences(ctx)
if err != nil {
return "", err
}
key := KeyFor(name)
for _, l := range all {
if l.Name != name && KeyFor(l.Name) == key {
return l.Name, nil
}
}
return "", nil
}
@@ -0,0 +1,72 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
// design 39 bus-and-switch §10, B6.
func TestALicencesKeyForm(t *testing.T) {
for name, key := range map[string]string{
"jane@example.org": "jane_at_example.org",
"Jane.Doe@Example.ORG": "jane.doe_at_example.org",
"0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5",
"team key_1": "team_key_1",
"a+b/c:d": "a_b_c_d",
"ops-key": "ops-key",
} {
if got := KeyFor(name); got != key {
t.Errorf("KeyFor(%q) = %q, want %q", name, got, key)
}
}
}
// design 39 bus-and-switch §10: an adoption whose key another licence has is refused, in the audit and as an event.
func TestAnAdoptionWhoseKeyIsTakenIsRefused(t *testing.T) {
ctx := context.Background()
t.Run("an API key", func(t *testing.T) {
mm := newMesh(t)
_ = mm.store.SaveLicence(ctx, Licence{Name: "Ops-Key", Kind: "api-key"})
_, err := mm.m.adoptKey(ctx, "ops-key", "sk-ant-"+strings.Repeat("a", 40), "a test")
if err == nil || !strings.Contains(err.Error(), "Ops-Key") {
t.Fatalf("adopted a key whose key form is taken: %v", err)
}
if l, _ := mm.store.Licence(ctx, "ops-key"); l != nil {
t.Fatal("the clashing licence was kept")
}
assertRefused(t, mm)
})
t.Run("a login", func(t *testing.T) {
mm := newMesh(t)
_ = mm.store.SaveLicence(ctx, Licence{Name: "ACCT-1@example.org", Kind: "api-key"})
r := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
adopted, err := mm.m.Consider(ctx, []Holdings{r})
if err != nil || len(adopted) != 0 {
t.Fatalf("adopted %v (%v), want none", adopted, err)
}
if len(mm.vendor.exchanged) != 0 {
t.Fatalf("a refused login was exchanged: %v", mm.vendor.exchanged)
}
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-laptop")); o != Refused {
t.Fatalf("outcome %q, want refused", o)
}
assertRefused(t, mm)
})
}
func assertRefused(t *testing.T, mm *miniMesh) {
t.Helper()
said := false
for _, e := range mm.events {
said = said || strings.HasPrefix(e, "licence.refused ")
}
audited := false
for _, a := range mm.audit.under("") {
audited = audited || a["what"] == "refused"
}
if !said || !audited {
t.Fatalf("refusal said %v, audited %v: %v", said, audited, mm.events)
}
}
@@ -3,9 +3,6 @@
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
//
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
@@ -16,7 +13,6 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -32,14 +28,6 @@ func say(format string, args ...any) {
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "prepare" {
if err := prepare(); err != nil {
say("preparing the store failed: %v", err)
os.Exit(1)
}
say("the store's schema is what this version needs")
return
}
go daemon()
if err := stdio.Serve("", tools()); err != nil {
say("%v", err)
@@ -47,17 +35,6 @@ func main() {
}
}
func prepare() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
store, err := PgStoreFromEnv(ctx)
if err != nil {
return err
}
defer store.Close()
return store.Migrate(ctx)
}
// ---- what the binary is handed -----------------------------------------------------------------------
var (
@@ -85,19 +62,21 @@ func manager() (*Manager, error) {
if err != nil {
return nil, err
}
store, err := PgStoreFromEnv(context.Background())
if err != nil {
return nil, err
}
node, _ := os.Hostname()
if n := os.Getenv("MESH_NODE"); n != "" {
node = n
}
bindings := stdio.State("bindings")
built = &Manager{
Store: store,
Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"),
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"),
AuditLog: stdio.Log("audit"), Now: time.Now},
Vendor: LiveVendor(),
Crypt: crypt,
// Today's database is the carry-over's source alone, read once and never written (§11, A3).
Carry: &Carry{State: stdio.State("carried"), Source: func(ctx context.Context) (CarrySource, error) {
return PgSourceFromEnv(ctx)
}},
Grants: VaultGrants{Keys: keys},
Keys: keys,
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
var a GrantAnswer
@@ -107,20 +86,48 @@ func manager() (*Manager, error) {
}
return a, json.Unmarshal(raw, &a)
},
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
_, err := bindings.Put(consumer, b)
Reported: heard.Of,
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Tell: func(_ context.Context, headline string, lines []string) error {
_, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"subject": Seat, "headline": headline,
"lines": lines})
return err
},
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"),
Conditions: stdio.State("conditions"),
Say: SayCondition,
ConfigLevels: func(context.Context) (map[string]string, error) { return configLevels(node) },
}
return built, nil
}
// configLevels asks mesh-config where each of the manager's own settings was set on its node (§40, B18): the source
// of each resolved value, by its key.
func configLevels(node string) (map[string]string, error) {
raw, err := stdio.Ask("seat:mesh-config.config", map[string]any{"action": "list", "module": Publisher, "node": node})
if err != nil {
return nil, err
}
var answer struct {
Resolved []struct {
Key string `json:"key"`
Source string `json:"source"`
} `json:"resolved"`
}
if err := json.Unmarshal(raw, &answer); err != nil {
return nil, fmt.Errorf("mesh-config's answer: %w", err)
}
levels := map[string]string{}
for _, r := range answer.Resolved {
levels[r.Key] = r.Source
}
return levels, nil
}
// keypair is this module's own, made once in its state directory; the private half never leaves it.
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
func keypair(dir string) (KeyPair, error) {
@@ -148,9 +155,33 @@ func keypair(dir string) (KeyPair, error) {
// ---- the long-running half ---------------------------------------------------------------------------
// heard is what every node last said it holds (ADR 0206, design 39 bus-and-switch §23), by node.
var heard = &Heard{}
// carryOverFirst runs the carry-over at the start, before the manager hears an event, refreshes or reads usage (§11), and
// answers once it has ended: at once when `carried.postgres` exists, else when a run writes it, this one or one asked
// by hand later.
func carryOverFirst() {
<-stdio.Served()
m, err := manager()
if err != nil {
say("the carry-over cannot start: %v", err)
return
}
if !m.Carry.Ended() {
bus, _ := m.Store.(*BusStore)
_, _ = m.Carry.Run(context.Background(), m, bus) // a failure is said, and the run is asked again by hand (§44)
}
// At its start the manager judges every condition anew: raised again each it holds, cleared each it no longer
// holds, carry-over-failed among them (§9, §11).
if err := m.SayConditions(context.Background(), true); err != nil {
say("saying the conditions at the start: %v", err)
}
<-m.Carry.Wait()
}
func daemon() {
var mu sync.Mutex
reports := map[string]Holdings{}
carryOverFirst()
var passing sync.Mutex
pass := func() {
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
@@ -160,14 +191,7 @@ func daemon() {
say("not ready: %v", err)
return
}
mu.Lock()
all := make([]Holdings, 0, len(reports))
for _, r := range reports {
all = append(all, r)
}
mu.Unlock()
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
adopted, err := m.Consider(context.Background(), all)
adopted, err := m.Consider(context.Background(), heard.All())
if err != nil {
say("considering the reports failed: %v", err)
}
@@ -176,48 +200,45 @@ func daemon() {
}
}
// What every node holds (ADR 0206): the whole current set, then each change. Asked again until it
// answers — the channel to the runtime opens as the bundle starts, and the agent module's state may
// arrive after this one.
// What every node holds (ADR 0206, design 39 bus-and-switch §23): each `holdings-changed` heard is that node's
// latest report and starts a pass; once heard, every node is asked to say it again (B12: at every start, after the
// carry-over). Asked again until it answers — the channel to the runtime opens as the bundle starts.
go func() {
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
for attempt := 0; ; attempt++ {
err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error {
mu.Lock()
if c.Op == "put" {
var h Holdings
if json.Unmarshal(c.Value, &h) == nil {
reports[c.Key] = h
}
} else {
delete(reports, c.Key)
}
mu.Unlock()
// During the current values the pass waits for the whole set: newest login first needs all.
if !c.Current {
go pass()
err := stdio.Subscribe(HoldingsChanged, func(e stdio.Envelope) error {
if _, err := heard.Take(e.Body); err != nil {
say("%v", err)
return nil
}
go pass()
return nil
})
if err == nil {
mu.Lock()
n := len(reports)
mu.Unlock()
say("watching what %d node(s) hold", n)
pass()
say("hearing what the nodes hold")
if m, err := manager(); err != nil {
say("not ready to ask what the nodes hold: %v", err)
} else if err := m.AskHoldings(Itself); err != nil {
say("asking what the nodes hold could not be said: %v", err)
}
return
}
pause := time.Minute
if attempt < len(waits) {
pause = waits[attempt]
}
say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause)
say("what the nodes hold cannot be heard yet (%v); asking again in %s", err, pause)
time.Sleep(pause)
}
}()
refresh := time.NewTicker(time.Minute)
usage := time.NewTicker(5 * time.Minute)
// Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings.
every := Defaults.UsageEvery
if m, err := manager(); err == nil {
every = m.settings().UsageEvery
}
usage := time.NewTimer(every)
for {
select {
case <-refresh.C:
@@ -237,7 +258,9 @@ func daemon() {
if err := m.ReadUsage(context.Background()); err != nil {
say("reading usage failed: %v", err)
}
every = m.settings().UsageEvery
}
usage.Reset(every)
}
}
}
@@ -253,7 +276,8 @@ func text(a map[string]any, k string) (string, error) {
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625).
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). A verb
// that acts answers nothing but "the carry-over has not ended" until it has (§11).
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
Run: func(a map[string]any) (any, error) {
@@ -261,11 +285,41 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
if err != nil {
return nil, err
}
if !reads[name] {
if err := m.Carry.Acts(); err != nil {
return nil, err
}
}
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
return run(ctx, m, a)
}}
}
// reads are the seat's verbs that only read, served while the carry-over has not ended (§11).
var reads = map[string]bool{"licences": true, "bindings": true, "usage": true, "public-key": true, "current": true}
// sinceOf is the argument since, RFC 3339; absent is the zero time.
func sinceOf(a map[string]any) (time.Time, error) {
v, _ := a["since"].(string)
if strings.TrimSpace(v) == "" {
return time.Time{}, nil
}
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v))
if err != nil {
return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err)
}
return t, nil
}
// limitOf is the argument limit: def when absent or not positive, at most most.
func limitOf(a map[string]any, def, most int) int {
limit := def
if v, ok := a["limit"].(float64); ok && v > 0 {
limit = int(v)
}
return min(limit, most)
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
@@ -278,11 +332,15 @@ func two(a map[string]any, k1, k2 string) (string, string, error) {
func tools() []stdio.Tool {
consumer := str("the node's name")
return []stdio.Tool{
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.",
return append([]stdio.Tool{
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it, its latest usage with its reset times, its room or why it is not known, whether it is where a full licence's consumers move next, when and from where it was adopted, and its open conditions. Never a token.",
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.",
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }),
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given; with consumer, that consumer's alone.",
map[string]any{"consumer": str("a consumer; absent for every one")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, _ := a["consumer"].(string)
return m.BindingsOf(ctx, strings.TrimSpace(c))
}),
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
@@ -318,15 +376,18 @@ func tools() []stdio.Tool {
}
return m.RotateDue(ctx)
}),
verb("usage", "Usage readings, the latest first, for every licence or one.",
map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}},
verb("usage", "Usage readings, the latest first, for every licence or one: the flattened reading with its three reset times, none before since, at most limit (default 20, at most 2000), and with raw the vendor's answer whole.",
map[string]any{"licence": str("a licence; absent for all"), "since": str("a time, RFC 3339: no reading before it"),
"limit": map[string]any{"type": "number", "description": "how many readings (default 20, at most 2000)"},
"raw": map[string]any{"type": "boolean", "description": "also the vendor's answer whole"}},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
l, _ := a["licence"].(string)
limit := 20
if v, ok := a["limit"].(float64); ok && v > 0 {
limit = int(v)
since, err := sinceOf(a)
if err != nil {
return nil, err
}
return m.Store.Usage(ctx, l, limit)
raw, _ := a["raw"].(bool)
return m.Store.Usage(ctx, l, since, limitOf(a, 20, 2000), raw)
}),
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
@@ -356,14 +417,14 @@ func tools() []stdio.Tool {
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
return map[string]any{"public_key": m.Keys.PublicKey}, nil
}),
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
verb("current", "For a consumer's agent module: which licence it is bound to, the licence's key, kind and generation, its health (ok, failing or expiring) and when its access token expires; never the token, which the vault hands. Null when it is bound to nothing.",
map[string]any{"consumer": consumer},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, k, err := two(a, "consumer", "public_key")
c, err := text(a, "consumer")
if err != nil {
return nil, err
}
return m.Current(ctx, c, k)
return m.Current(ctx, c)
}),
}
}, moduleTools()...)
}
@@ -23,7 +23,10 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
@@ -53,17 +56,6 @@ type Holdings struct {
Generation int64 `json:"generation"`
}
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
// generation.
type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
type Cause struct {
@@ -90,63 +82,48 @@ func CauseOf(ctx context.Context) Cause {
return Cause{Why: "a rule of this module's own", By: Itself}
}
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
_ = m.Store.Audit(ctx, what, detail)
// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in
// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and
// `rotated` are said no more (B16). Never a token.
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
if err := m.Store.Audit(ctx, what, detail); err != nil {
m.Log("%s, but %v", line, err)
return err
}
m.Log("%s", line)
_ = m.Emit("licence."+what, detail)
return nil
}
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
if from != "" {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a
// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token.
func (m *Manager) changed(b Binding, from string) {
key := ""
if b.Licence != "" {
key = KeyFor(b.Licence)
}
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
"why": c.Why, "by": c.By})
_ = m.Emit("binding.changed", map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
"kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By,
"at": m.Now().UTC().Format(time.RFC3339Nano)})
}
// Settings are the manager's own, declared with defaults (design 39 §8).
type Settings struct {
Cadence time.Duration // rotate a grant once older than this
Floor time.Duration // refresh in any case with less than this left
FailuresToNotify int
Cooldown time.Duration // at most one notification per licence in this window
RefreshWarn time.Duration // warn this long before a refresh token itself expires
// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27).
func switchedFields(from, to string, consumers []string, c Cause) map[string]any {
return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why}
}
// Defaults are the settings a fresh mesh runs with.
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour}
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
func SettingsFrom(path string) Settings {
s := Defaults
raw, err := os.ReadFile(path)
if err != nil {
return s
// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with
// §27's fields), and as binding.changed.
func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error {
what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence)
detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation,
"why": c.Why, "by": c.By}
if from != "" && from != b.Licence {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence)
detail = switchedFields(from, b.Licence, []string{b.Consumer}, c)
}
var f map[string]float64
if json.Unmarshal(raw, &f) != nil {
return s
}
set := func(k string, unit time.Duration, into *time.Duration) {
if v := f[k]; v > 0 {
*into = time.Duration(v * float64(unit))
}
}
set("cadence_minutes", time.Minute, &s.Cadence)
set("floor_minutes", time.Minute, &s.Floor)
set("cooldown_hours", time.Hour, &s.Cooldown)
if v := f["refresh_warn_days"]; v > 0 {
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
}
if v := f["failures_to_notify"]; v > 0 {
s.FailuresToNotify = int(v)
}
return s
err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail)
m.changed(b, from)
return err
}
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
@@ -160,20 +137,38 @@ type GrantAnswer struct {
type Manager struct {
Store Store
Vendor Vendor
Crypt *Crypt
// Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3).
Crypt *Crypt
// Carry is the carry-over's gate (§11): no act until it has ended. Nil is a manager with nothing to carry over.
Carry *Carry
// Grants is the vault, which holds every licence's grant and access token (§15, §16).
Grants Grants
Keys KeyPair
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
// is the node not answering; a nil Sealed is no login waiting.
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
// PutBinding and DeleteBinding are this module's `bindings` state.
PutBinding func(ctx context.Context, consumer string, b BindingState) error
DeleteBinding func(ctx context.Context, consumer string) error
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
// Reported is the binding generation a node last reported applying (B11), 0 when none is known.
Reported func(node string) int64
Emit func(event string, body map[string]any) error
// Tell tells the operator one notice that needs no answer, through the seat operator-channel's tell (§28).
Tell func(ctx context.Context, headline string, lines []string) error
Now func() time.Time
Log func(format string, args ...any)
// Holder names this process in a lease, so a second run is told apart.
Holder string
Settings Settings
Holder string
// Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile
// keeps them.
Settings Settings
SettingsFile string
settingsMu sync.RWMutex
// Conditions is the state `conditions` (design 39 bus-and-switch §9), and Say says one condition on the operator
// channel; either nil says none.
Conditions keptState
Say func(c oc.Condition) error
conditionsMu sync.Mutex
// ConfigLevels is where mesh-config says each setting was set on the manager's node (mesh, node or default), by
// setting (§40, B18); nil answers every one as the default.
ConfigLevels func(ctx context.Context) (map[string]string, error)
}
const leaseFor = 2 * time.Minute
@@ -239,17 +234,9 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
// exchanged. Answers what was adopted.
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
// counts from one again, while every node still holds the number it last applied. The nodes no longer
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
// skipped. So the count is moved past every number reported, before anything here binds.
var highest int64
for _, r := range reports {
highest = max(highest, r.Generation)
}
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
return nil, err
}
// **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own,
// and moves past the number its node reported applying.
floor := m.floorIn(reports)
byAccount, err := m.CandidatesIn(ctx, reports)
if err != nil {
return nil, err
@@ -262,19 +249,19 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
if err := m.bindReporters(ctx, reports); err != nil {
if err := m.bindReporters(ctx, reports, floor); err != nil {
return nil, err
}
var adopted []string
for _, account := range accounts {
list := byAccount[account]
key := "account:" + account
key := "account." + account
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
if err != nil || !ok {
continue
}
for i, c := range list {
name, err := m.adoptOne(ctx, c, reports)
name, err := m.adoptOne(ctx, c, reports, floor)
if err != nil {
m.Log("adopting %s's login failed: %v", c.Node, err)
continue
@@ -293,7 +280,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
}
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor func(string) int64) error {
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
continue
@@ -311,21 +298,19 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
if l == nil {
continue
}
b, err := m.Store.Bind(ctx, rep.Node, l.Name)
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
if err != nil {
return err
}
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
if err := m.moved(ctx, b, "", c); err != nil {
return err
}
}
return nil
}
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, floor func(string) int64) (string, error) {
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
if err != nil {
// Not answering is not an answer: asked again on the next pass.
@@ -348,12 +333,34 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
return "", nil
}
// A new licence whose key another licence already has is refused before its grant is exchanged
// (design 39 bus-and-switch §10).
if held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID); err != nil {
return "", err
} else if held == nil {
taken, err := m.keyTaken(ctx, NameFor(c.Identity))
if err != nil {
return "", err
}
if taken != "" {
why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why)
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why})
return "", nil
}
}
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
// only holder of a live refresh token for the account.
r := m.Vendor.Refresh(ctx, offered)
if !r.OK {
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
@@ -363,7 +370,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if r.Account != "" && r.Account != c.Identity.AccountUUID {
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
"reason": "the account the node reported is not the one the vendor answered for"})
return "", nil
@@ -375,9 +384,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
}
now := m.Now().UnixMilli()
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID, AdoptedFrom: c.Node}
if held != nil {
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
// A newer login keeps what the key holds beside its grant: its latest reading (§2, §24).
l.Name, l.NotifiedAt, l.Generation, l.Usage = held.Name, held.NotifiedAt, held.Generation, held.Usage
if l.Email == "" {
l.Email = held.Email
}
@@ -385,7 +395,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
l.OrganizationUUID = held.OrganizationUUID
}
}
m.keep(&l, r.Grant)
if err := m.keep(ctx, &l, r.Grant); err != nil {
return "", err
}
l.AdoptedAt = max(now, c.ChangedAt)
if err := m.Store.SaveLicence(ctx, l); err != nil {
return "", err
@@ -395,18 +407,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
why = "a newer login"
}
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""})
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""}); err != nil {
return "", err
}
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
ctx = WithCause(ctx, cause)
// The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once
// (§21).
if err := m.publishAdvance(ctx, l, floor); err != nil {
return "", err
}
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
return "", err
} else if b == nil || b.Licence != l.Name {
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
moved, err := m.Store.Bind(ctx, c.Node, l.Name, Move{Kind: l.Kind, Why: cause.Why, By: cause.By, Floor: floor})
if err != nil {
return "", err
}
@@ -414,7 +433,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if b != nil {
from = b.Licence
}
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
if err := m.moved(ctx, moved, from, cause); err != nil {
return "", err
}
}
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
@@ -423,23 +444,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
continue
}
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
if err := m.moved(ctx, b, "", bc); err != nil {
return "", err
}
}
}
if err := m.publishAdvance(ctx, l); err != nil {
return "", err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
return l.Name, nil
}
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
func (m *Manager) keep(l *Licence, g FullGrant) {
raw, _ := json.Marshal(g)
l.Sealed = m.Crypt.Seal(string(raw))
// keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39
// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns.
func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error {
grant, _ := json.Marshal(g)
if err := m.handOver(ctx, l, string(grant), accessOf(g)); err != nil {
return err
}
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
l.AccessExpiresAt = g.ExpiresAt
if g.RefreshTokenExpiresAt != nil {
@@ -447,30 +470,73 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
}
l.Failures = 0
l.RotatedAt = m.Now().UnixMilli()
return nil
}
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
bindings, err := m.Store.Advance(ctx, l.Name)
if err != nil {
return err
}
c := CauseOf(ctx)
for _, b := range bindings {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return err
// accessOf is a subscription's `access` secret: its access token and expiry, never its refresh token (§15).
func accessOf(g FullGrant) string {
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
return string(access)
}
// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15).
func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error {
generation := l.Generation + 1
for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} {
if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil {
return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err)
}
}
l.Generation = generation
return nil
}
// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends.
func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
var g FullGrant
plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant)
if err != nil {
return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err)
}
return g, json.Unmarshal([]byte(plain), &g)
}
// publishAdvance gives every consumer of a licence a new generation, with the cause ctx carries, so the node says why
// it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
c := CauseOf(ctx)
advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
for _, b := range advanced {
m.changed(b, b.Licence)
}
return err
}
// floorIn is the floor of each consumer's generation (B11): the higher of what its node reported in reports and what
// Reported says it last applied. A worker's node is the part of its name before the first dot (ADR 0183 §19).
func (m *Manager) floorIn(reports []Holdings) func(string) int64 {
return func(consumer string) int64 {
node, _, _ := strings.Cut(consumer, ".")
var f int64
for _, r := range reports {
if r.Node == node {
f = max(f, r.Generation)
}
}
if m.Reported != nil {
f = max(f, m.Reported(node))
}
return f
}
}
// ---- keeping grants alive ----------------------------------------------------------------------------
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
func Due(l Licence, now time.Time, s Settings) bool {
if l.Kind != "subscription" || l.Sealed == "" {
if l.Kind != "subscription" || l.Generation == 0 {
return false
}
ms := now.UnixMilli()
@@ -484,7 +550,7 @@ func Due(l Licence, now time.Time, s Settings) bool {
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
// whatever the age — the seat's `refresh` verb.
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
key := "licence:" + name
key := "licence." + KeyFor(name)
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
if err != nil {
return nil, err
@@ -500,37 +566,38 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
if l == nil {
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
}
if l.Kind != "subscription" || l.Sealed == "" {
if l.Kind != "subscription" || l.Generation == 0 {
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
}
now := m.Now()
if !force && !Due(*l, now, m.Settings) {
if !force && !Due(*l, now, m.settings()) {
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
}
plain, err := m.Crypt.Open(l.Sealed)
g, err := m.grantOf(ctx, *l)
if err != nil {
return nil, err
}
var g FullGrant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return nil, err
}
r := m.Vendor.Refresh(ctx, g)
g = FullGrant{}
if !r.OK {
l.Failures++
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil {
return nil, err
}
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.settings().FailuresToNotify)
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
}
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
}
m.keep(l, r.Grant)
if err := m.keep(ctx, l, r.Grant); err != nil {
return nil, err
}
if l.RefreshExpiresAt != 0 {
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
left < m.Settings.RefreshWarn)
left < m.settings().RefreshWarn)
}
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
@@ -545,10 +612,12 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
}
}
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
if err := m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
if err := m.publishAdvance(ctx, *l); err != nil {
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil {
return nil, err
}
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
@@ -560,7 +629,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) {
return
}
now := m.Now().UnixMilli()
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() {
return
}
l.NotifiedAt = now
@@ -575,7 +644,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
}
var out []map[string]any
for _, l := range all {
if Due(l, m.Now(), m.Settings) {
if Due(l, m.Now(), m.settings()) {
r, err := m.Rotate(ctx, l.Name, false)
if err != nil {
return out, err
@@ -586,79 +655,165 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
return out, nil
}
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
// ReadUsage reads every subscription's usage, on the manager's cadence (§24): each reading written, the rule applied
// after it (§26), and the conditions said (§9).
func (m *Manager) ReadUsage(ctx context.Context) error {
_, err := m.ReadUsageOf(ctx, "", false)
return err
}
// Read is one reading as licences_read_usage answers it (§38): the licence, the reading or why it failed, and what §26
// did or, with dry, would do.
type Read struct {
Licence string `json:"licence"`
Reading *UsageReading `json:"reading,omitempty"`
Error string `json:"readError,omitempty"`
Switches []Switch `json:"switches"`
}
// ReadUsageOf reads the usage of one subscription, or every one when licence is empty, as §24 does, and applies §26
// after each reading unless dry; dry answers what §26 would do (§32, §38).
func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([]Read, error) {
if err := m.ReadSettings(ctx); err != nil {
return nil, err
}
all, err := m.Store.Licences(ctx)
if err != nil {
return err
return nil, err
}
out := []Read{}
for _, l := range all {
if l.Kind != "subscription" || l.Sealed == "" {
if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) {
continue
}
plain, err := m.Crypt.Open(l.Sealed)
// The reading takes the licence's grant under its lease, so it never reads a grant a refresh is replacing (§4,
// §16); a grant the vault does not answer is a failed reading like a vendor's refusal (§31).
key := "licence." + KeyFor(l.Name)
leased, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
if err != nil {
return err
return out, err
}
var g FullGrant
_ = json.Unmarshal([]byte(plain), &g)
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
if err != nil || raw == nil {
if !leased {
out = append(out, Read{Licence: l.Name, Error: "another run holds its lease", Switches: []Switch{}})
continue
}
unlease := func() { _ = m.Store.Unlease(ctx, key, m.Holder) }
if fresh, err := m.Store.Licence(ctx, l.Name); err == nil && fresh != nil {
l = *fresh
}
var raw map[string]any
g, err := m.grantOf(ctx, l)
if err == nil {
raw, err = m.Vendor.Usage(ctx, g.AccessToken)
}
g = FullGrant{}
if err == nil && raw == nil {
err = errors.New("the usage endpoint answered nothing")
}
if err != nil {
// A failed reading keeps the last percentages and writes the vendor's status and reason (§24).
kept := UsageReading{}
if l.Usage != nil {
kept = *l.Usage
}
kept.ReadError = err.Error()
l.Usage = &kept
err := m.Store.SaveLicence(ctx, l)
unlease()
if err != nil {
return out, err
}
m.Log("reading the usage of %s failed: %v", l.Name, err)
out = append(out, Read{Licence: l.Name, Error: kept.ReadError, Switches: []Switch{}})
continue
}
reading := FlattenUsage(raw)
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
return err
unlease()
return out, err
}
// The latest reading beside its licence, so licences answers from one key (§2).
latest := reading
latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano)
l.Usage = &latest
err = m.Store.SaveLicence(ctx, l)
unlease()
if err != nil {
return out, err
}
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
"sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct,
"weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct,
"sonnetResetsAt": reading.SonnetResetsAt})
// On each reading, a licence at its threshold moves every consumer bound to it (§26).
acts, err := m.ApplyRule(ctx, l.Name, false, dry)
if acts == nil {
acts = []Switch{}
}
out = append(out, Read{Licence: l.Name, Reading: &latest, Switches: acts})
if err != nil {
return out, err
}
}
return nil
// What the readings and the settings now hold is said, each condition only when what it says changed (§9).
return out, m.SayConditions(ctx, false)
}
// ---- the seat's verbs --------------------------------------------------------------------------------
// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access
// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to
// Current is which licence a consumer is bound to and its health, never the token (design 39 bus-and-switch §18, B7,
// B19): the licence, its key (the name of its secrets in the vault), kind, generation, health and accessExpiresAt, and
// for a subscription the account's identity the agent module points its account file at. Nil when it is bound to
// nothing.
func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) {
if !strings.Contains(publicKey, "PUBLIC KEY") {
return nil, errors.New("current seals to the consumer's public key, and none was given")
}
func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, error) {
b, err := m.Store.Binding(ctx, consumer)
if err != nil || b == nil {
return nil, err
}
l, err := m.Store.Licence(ctx, b.Licence)
if err != nil || l == nil || l.Sealed == "" {
if err != nil || l == nil {
return nil, err
}
plain, err := m.Crypt.Open(l.Sealed)
if err != nil {
return nil, err
out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation,
"health": Health(*l, m.Now(), m.settings())}
if l.AccessExpiresAt != 0 {
out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)
}
handed := plain
if l.Kind == "subscription" {
var g FullGrant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return nil, err
}
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
handed = string(access)
}
box, err := Seal(handed, publicKey)
if err != nil {
return nil, err
}
out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box}
if l.AccountUUID != "" {
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
}
return out, nil
}
// Health is a licence's health (B19): `failing` once its refresh failed failures_to_notify times in a row, `expiring`
// when its refresh token expires within refresh_warn_days, else `ok`.
func Health(l Licence, now time.Time, s Settings) string {
switch {
case l.Failures >= s.FailuresToNotify:
return "failing"
case l.RefreshExpiresAt != 0 && l.RefreshExpiresAt-now.UnixMilli() < s.RefreshWarn.Milliseconds():
return "expiring"
}
return "ok"
}
// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged, each with
// its licence's key beside the §3 fields (B25).
func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) {
all, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
out := []Binding{}
for _, b := range all {
if consumer == "" || b.Consumer == consumer {
b.Key = KeyFor(b.Licence)
out = append(out, b)
}
}
return out, nil
}
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
@@ -673,7 +828,8 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if err != nil {
return nil, err
}
b, err := m.Store.Bind(ctx, consumer, licence)
c := CauseOf(ctx)
b, err := m.Store.Bind(ctx, consumer, licence, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: m.floorIn(nil)})
if err != nil {
return nil, err
}
@@ -681,12 +837,12 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if before != nil {
from = before.Licence
}
c := CauseOf(ctx)
m.moved(ctx, consumer, from, licence, b.Generation, c)
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
if err := m.moved(ctx, b, from, c); err != nil {
return nil, err
}
if from != "" && from != licence {
_ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36)
}
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
}
@@ -699,12 +855,12 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
}
if err := m.DeleteBinding(ctx, consumer); err != nil {
c := CauseOf(ctx)
if err := m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
c := CauseOf(ctx)
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence)
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
}
@@ -751,22 +907,44 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
if held != nil && held.Kind != "api-key" {
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
}
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
taken, err := m.keyTaken(ctx, name)
if err != nil {
return nil, err
}
if taken != "" {
why := "its key " + KeyFor(name) + " is the key of the licence " + taken
if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil {
return nil, err
}
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
return nil, fmt.Errorf("%s is refused: %s", name, why)
}
l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli(), AdoptedFrom: from}
if held != nil {
l.Generation, l.Usage = held.Generation, held.Usage
}
if err := m.handOver(ctx, &l, key, key); err != nil {
return nil, err
}
if err := m.Store.SaveLicence(ctx, l); err != nil {
return nil, err
}
c := CauseOf(ctx)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
if err := m.publishAdvance(ctx, l); err != nil {
if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil {
return nil, err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
}
// Licences is each licence as a person reads it: health and who is bound, never a token.
// Licences is each licence as a person reads it (design 39 bus-and-switch §34, §47, B19, B40): today's fields, its
// latest usage with its reset times, its room or why it is not known, whether it is the target a full licence's
// consumers move to next, when and from where it was adopted, and the conditions open about it. Never a token.
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
all, err := m.Store.Licences(ctx)
if err != nil {
@@ -776,27 +954,52 @@ func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
if err != nil {
return nil, err
}
open, err := m.KeptConditions()
if err != nil {
return nil, err
}
s := m.settings()
stamp := func(ms int64) any {
if ms == 0 {
return nil
}
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}
targets := map[string]bool{}
for _, l := range all {
if len(consumersOf(l.Name, bindings)) > 0 {
if to, _ := m.targetFor(l, all, s); to != "" {
targets[to] = true
}
}
}
out := []map[string]any{}
for _, l := range all {
bound := []string{}
for _, b := range bindings {
if b.Licence == l.Name {
bound = append(bound, b.Consumer)
}
bound := consumersOf(l.Name, bindings)
if bound == nil {
bound = []string{}
}
account := l.Email
if account == "" {
account = l.AccountUUID
}
var room any = m.roomWords(l, s)
if r, known := Room(l, m.Now(), s); known {
room = r
}
conditions := []map[string]any{}
prefix := "licence." + KeyFor(l.Name) + "."
for _, c := range open {
if strings.HasPrefix(c.Body.Key, prefix) {
conditions = append(conditions, map[string]any{"key": c.Body.Key, "severity": c.Body.Severity,
"headline": c.Body.Headline, "since": c.Since})
}
}
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
"accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt),
"rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound})
"organisation": l.OrganizationUUID, "accessExpiresAt": stamp(l.AccessExpiresAt),
"refreshExpiresAt": stamp(l.RefreshExpiresAt), "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures,
"bound": bound, "usage": l.Usage, "room": room, "target": targets[l.Name], "adoptedAt": stamp(l.AdoptedAt),
"adoptedFrom": l.AdoptedFrom, "conditions": conditions})
}
return out, nil
}
@@ -22,6 +22,9 @@ type stubVendor struct {
issued int
account string
now func() time.Time
// usage is what the usage endpoint answers, and usageErr its failure; nil answers a session window alone.
usage map[string]any
usageErr error
}
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
@@ -44,31 +47,90 @@ func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
}
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
if v.usageErr != nil {
return nil, v.usageErr
}
if v.usage != nil {
return v.usage, nil
}
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
}
// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept.
type memGrants struct {
mu sync.Mutex
held map[string]heldGrant
puts []string
// refuseAccess is the vault refusing a licence's access token to the manager's machine, as it does for a licence no
// binding there names (§17).
refuseAccess bool
}
type heldGrant struct {
generation int64
value string
}
func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
g.mu.Lock()
defer g.mu.Unlock()
name := SecretName(key, part)
if h, ok := g.held[name]; ok && h.generation == generation && h.value == value {
return nil // the vault keeps what it holds, unchanged
} else if ok && h.generation >= generation {
return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation)
}
g.held[name] = heldGrant{generation, value}
g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation))
return nil
}
func (g *memGrants) Get(_ context.Context, key, part string) (string, error) {
g.mu.Lock()
defer g.mu.Unlock()
h, ok := g.held[SecretName(key, part)]
if g.refuseAccess && part == PartAccess {
return "", fmt.Errorf("no consumer on this machine is bound to the licence %s", key)
}
if !ok {
return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part))
}
return h.value, nil
}
type miniMesh struct {
m *Manager
store *MemoryStore
vendor *stubVendor
logins map[string]FullGrant // node → what its credentials file holds
state map[string]BindingState
events []string
now time.Time
keys KeyPair
askDown bool
grants *memGrants
m *Manager
store *BusStore
usage *memLog
audit *memLog
licences *memState
bindings *memState
leases *memState
offered *memState
vendor *stubVendor
logins map[string]FullGrant // node → what its credentials file holds
events []string
now time.Time
keys KeyPair
askDown bool
}
func newMesh(t *testing.T) *miniMesh {
t.Helper()
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0}
mm := &miniMesh{logins: map[string]FullGrant{}, now: t0,
grants: &memGrants{held: map[string]heldGrant{}}}
now := func() time.Time { return mm.now }
mm.store = NewMemoryStore(now)
mm.usage, mm.audit = newMemLog(now), newMemLog(now)
mm.licences, mm.bindings = newMemState(now), newMemState(now)
mm.leases, mm.offered = newMemState(now), newMemState(now)
mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases,
OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now}
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
crypt, _ := NewCrypt("a key the vault made")
mm.keys, _ = GenerateKeyPair()
mm.m = &Manager{
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Grants: mm.grants, Keys: mm.keys,
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
if mm.askDown {
return GrantAnswer{}, fmt.Errorf("503 no responders")
@@ -81,8 +143,6 @@ func newMesh(t *testing.T) *miniMesh {
box, err := Seal(string(raw), publicKey)
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
},
PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil },
DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil },
Emit: func(event string, body map[string]any) error {
raw, _ := json.Marshal(body)
mm.events = append(mm.events, event+" "+string(raw))
@@ -93,6 +153,15 @@ func newMesh(t *testing.T) *miniMesh {
return mm
}
// bound is a consumer's value in the state bindings, as its readers decode it.
func (mm *miniMesh) bound(consumer string) BindingState {
var b BindingState
if e, _ := mm.bindings.Get(consumer); e != nil {
_ = json.Unmarshal(e.Value, &b)
}
return b
}
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
@@ -129,15 +198,14 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
}
// A first binding follows the login: both nodes reported this account and were bound to nothing.
bs, _ := mm.store.Bindings(ctx)
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.state)
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.bindings.values)
}
// The adoption is said, and so is each binding it made (novox/hq issue 625).
// The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21).
events := strings.Join(mm.events, "|")
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
if !strings.Contains(events, want) {
t.Fatalf("events lack %s: %v", want, mm.events)
for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} {
if strings.Count(events, want) != 1 {
t.Fatalf("events say %s other than once: %v", want, mm.events)
}
}
}
@@ -169,6 +237,12 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
before, _ := mm.store.Licence(ctx, licence1)
// §2: a licence says where it was adopted from, and a newer login keeps the latest reading the key holds.
pct := 42.0
before.Usage = &UsageReading{SessionPct: &pct, ReadAt: t0.Format(time.RFC3339Nano)}
if before.AdoptedFrom != "laptop" || mm.store.SaveLicence(ctx, *before) != nil {
t.Fatalf("the adopted licence is %+v", before)
}
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
@@ -179,6 +253,9 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
}
if after.AdoptedFrom != "desktop" || after.Usage == nil || after.Usage.SessionPct == nil || *after.Usage.SessionPct != 42 {
t.Fatalf("after a newer login the licence is %+v", after)
}
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
t.Fatalf("one account became %d licences", len(ls))
}
@@ -201,11 +278,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
mm.now = t0.Add(5 * time.Hour)
second := *mm.m
second.Holder = "another run"
second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys,
AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run",
Settings: mm.m.Settings}
var wg sync.WaitGroup
results := make([]map[string]any, 2)
for i, m := range []*Manager{mm.m, &second} {
for i, m := range []*Manager{mm.m, second} {
wg.Add(1)
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
}
@@ -228,9 +306,9 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g0 := mm.state["laptop"].Generation
g0 := mm.bound("laptop").Generation
_, _ = mm.m.Rotate(ctx, licence1, true)
g1 := mm.state["laptop"].Generation
g1 := mm.bound("laptop").Generation
if g1 <= g0 {
t.Fatalf("a rotation went from %d to %d", g0, g1)
}
@@ -242,43 +320,54 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
t.Fatal(err)
}
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
if mm.bound("laptop").Licence != "api" || mm.bound("laptop").Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.bound("laptop"))
}
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
t.Fatal(err)
}
if _, ok := mm.state["laptop"]; ok {
if _, ok := mm.bindings.values["laptop"]; ok {
t.Fatal("a released consumer still has a binding in the state")
}
}
func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) {
// design 39 bus-and-switch §18, B7, B19: no answer of current holds a token; it names the licence's key and health.
func TestCurrentHoldsNoToken(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
node, _ := GenerateKeyPair()
answer, err := mm.m.Current(ctx, "laptop", node.PublicKey)
if err != nil || answer["kind"] != "subscription" {
answer, err := mm.m.Current(ctx, "laptop")
if err != nil || answer["kind"] != "subscription" || answer["key"] != KeyFor(licence1) || answer["health"] != "ok" ||
answer["accessExpiresAt"] == nil || answer["identity"] == nil {
t.Fatalf("%v %v", answer, err)
}
box := answer["sealed"].(SealedBox)
plain, err := Open(box, node.PrivateKey)
if err != nil {
t.Fatal(err)
raw, _ := json.Marshal(answer)
for _, token := range []string{"rt-", "at-", "sealed"} {
if strings.Contains(string(raw), token) {
t.Fatalf("current answered %s: %s", token, raw)
}
}
var handed map[string]any
_ = json.Unmarshal([]byte(plain), &handed)
if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil {
t.Fatalf("handed %v", handed)
l, _ := mm.store.Licence(ctx, licence1)
for failures, health := range map[int]string{0: "ok", 3: "failing"} {
l.Failures = failures
if got := Health(*l, mm.now, mm.m.Settings); got != health {
t.Errorf("%d failures: %s, want %s", failures, got, health)
}
}
other, _ := GenerateKeyPair()
if _, err := Open(box, other.PrivateKey); err == nil {
t.Fatal("the hand-over opened with another key")
l.Failures, l.RefreshExpiresAt = 0, mm.now.Add(48*time.Hour).UnixMilli()
if got := Health(*l, mm.now, mm.m.Settings); got != "expiring" {
t.Errorf("a refresh token expiring in two days: %s", got)
}
if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil {
if a, _ := mm.m.Current(ctx, "nobody"); a != nil {
t.Fatalf("a consumer bound to nothing was answered %v", a)
}
if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" {
t.Fatalf("bindings of laptop: %v", bs)
}
// design 39 bus-and-switch B25: the verb bindings answers each binding's licence key beside the §3 fields.
if bs, _ := mm.m.BindingsOf(ctx, ""); len(bs) == 0 || bs[0].Key != KeyFor(bs[0].Licence) {
t.Fatalf("bindings answered %+v", bs)
}
}
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
@@ -290,15 +379,15 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
_ = mm.m.ReadUsage(ctx)
ls, _ := mm.m.Licences(ctx)
bs, _ := mm.store.Bindings(ctx)
everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs})
everything, _ := json.Marshal([]any{mm.events, mm.bindings.values, mm.licences.values, ls, bs})
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
if strings.Contains(string(everything), token) {
t.Fatalf("%s was published: %s", token, everything)
}
}
row, _ := mm.store.Licence(ctx, licence1)
if strings.Contains(row.Sealed, "rt-") {
t.Fatal("the grant is stored in the clear")
if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") {
t.Fatalf("the licence keeps a token: %s", raw)
}
}
@@ -324,22 +413,40 @@ func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing
}
}
func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
// The agent module's own report shape (claude-code's holdingsOf), parsed here.
raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription",
"refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},
"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0,
"changedAt":"2026-10-04T11:00:00.000Z"}`
var h Holdings
if err := json.Unmarshal([]byte(raw), &h); err != nil {
// design 39 bus-and-switch §23, §46, B12, B22: a holdings-changed, decoded from a copy of the agent module's form, is
// kept as its node's latest report, and one carrying a fingerprint the manager does not hold is a candidate; the
// manager asks every node with holdings.asked, its time and who asks.
func TestAHoldingsChangedIsHeardAndMakesACandidate(t *testing.T) {
mm := newMesh(t)
raw, err := os.ReadFile("testdata/holdings-changed.json")
if err != nil {
t.Fatal(err)
}
heard := &Heard{}
if _, err := heard.Take(raw); err != nil {
t.Fatal(err)
}
all := heard.All()
if len(all) != 1 || heard.Of("laptop") != 4 {
t.Fatalf("heard %+v", all)
}
h := all[0]
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
t.Fatalf("%+v", h)
}
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
t.Fatalf("the report's time does not parse: %v", err)
}
if c, err := mm.m.CandidatesIn(context.Background(), all); err != nil || len(c["u-1"]) != 1 {
t.Fatalf("candidates %v %v", c, err)
}
if _, err := heard.Take([]byte(`{"kind":"subscription"}`)); err == nil {
t.Fatal("a report naming no node was kept")
}
if err := mm.m.AskHoldings(Itself); err != nil || len(mm.events) != 1 ||
mm.events[0] != HoldingsAsked+` {"at":"`+t0.UTC().Format(time.RFC3339Nano)+`","by":"claude-licence-manager"}` {
t.Fatalf("said %v %v", mm.events, err)
}
}
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
@@ -350,15 +457,15 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Licence != licence1 {
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
if mm.bound("laptop").Licence != licence1 {
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.bindings.values)
}
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
t.Fatal("the older login was exchanged")
}
g := mm.state["laptop"].Generation
g := mm.bound("laptop").Generation
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Generation != g {
if mm.bound("laptop").Generation != g {
t.Fatal("a node already bound was bound again")
}
}
@@ -368,8 +475,8 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("%v", mm.state)
if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
t.Fatalf("%v", mm.bindings.values)
}
mm.now = t0.Add(time.Hour)
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
@@ -379,11 +486,11 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
t.Fatalf("adopted %v", adopted)
}
if mm.state["laptop"].Licence != "acct-2@example.org" {
t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"])
if mm.bound("laptop").Licence != "acct-2@example.org" {
t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop"))
}
if mm.state["server"].Licence != licence1 {
t.Fatalf("another node moved: %v", mm.state["server"])
if mm.bound("server").Licence != licence1 {
t.Fatalf("another node moved: %v", mm.bound("server"))
}
ls, _ := mm.store.Licences(ctx)
if len(ls) != 2 {
@@ -396,11 +503,11 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g := mm.state["laptop"]
g := mm.bound("laptop")
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
if mm.state["laptop"] != g {
t.Fatalf("a dead login moved its node: %v", mm.state["laptop"])
if mm.bound("laptop") != g {
t.Fatalf("a dead login moved its node: %v", mm.bound("laptop"))
}
}
@@ -413,9 +520,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
if err != nil || r["adopted"] != true {
t.Fatalf("%v %v", r, err)
}
l, _ := mm.store.Licence(ctx, "api")
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" {
t.Fatalf("stored %q", plain)
for _, part := range []string{PartGrant, PartAccess} {
if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" {
t.Fatalf("the vault holds %q as the %s", plain, part)
}
}
other, _ := GenerateKeyPair()
wrong, _ := Seal("sk", other.PublicKey)
@@ -439,7 +547,7 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
t.Fatal(err)
}
if g := mm.state["laptop"].Generation; g <= 16 {
if g := mm.bound("laptop").Generation; g <= 16 {
t.Fatalf("the laptop applied generation 16 and was given %d", g)
}
}
@@ -460,28 +568,63 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
}
}
// changedSince is the binding.changed events said from mark on, decoded.
func (mm *miniMesh) changedSince(mark int) []map[string]any {
var out []map[string]any
for _, e := range mm.events[mark:] {
if body, ok := strings.CutPrefix(e, "binding.changed "); ok {
var v map[string]any
_ = json.Unmarshal([]byte(body), &v)
out = append(out, v)
}
}
return out
}
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39
// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key,
// after its key is written or deleted; a switch says one licence.switched with §27's fields.
func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) {
mm := newMesh(t)
var lines []string
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
ctx := context.Background()
once := func(mark int, consumer, licence, by string) map[string]any {
t.Helper()
got := mm.changedSince(mark)
if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by {
t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by)
}
if licence != "" && got[0]["key"] != KeyFor(licence) {
t.Fatalf("binding.changed names the key %v", got[0]["key"])
}
if mm.bound(consumer).Licence != licence {
t.Fatalf("said before the key was written: %+v", mm.bound(consumer))
}
return got[0]
}
mark := len(mm.events)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
t.Fatalf("a login's binding said %+v", s)
}
once(mark, "laptop", licence1, Itself)
file := filepath.Join(t.TempDir(), "key")
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
t.Fatal(err)
}
mark = len(mm.events)
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil {
t.Fatalf("the switch said %v", e)
}
if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" {
t.Fatalf("the node is told %+v", s)
}
said := strings.Join(lines, "\n")
@@ -489,34 +632,46 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
t.Fatalf("the journal said:\n%s", said)
}
var switched string
var switched []string
for _, e := range mm.events {
if strings.HasPrefix(e, "licence.switched ") {
switched = e
switched = append(switched, e)
}
}
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
if !strings.Contains(switched, want) {
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
if len(switched) != 1 {
t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events)
}
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} {
if !strings.Contains(switched[0], want) {
t.Fatalf("the event %q lacks %s", switched[0], want)
}
}
last := mm.store.Audits[len(mm.store.Audits)-1]
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
all := mm.audit.under("switched")
if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) {
t.Fatalf("the audit kept %s", raw)
}
_, _ = mm.m.Bind(ctx, "server", licence1)
mark = len(mm.events)
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
}
once(mark, "server", licence1, "novox.node-tools")
mark = len(mm.events)
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
t.Fatal(err)
}
if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" {
t.Fatalf("the release said %v", e)
}
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
t.Fatalf("the journal said:\n%s", said)
}
for _, e := range mm.events {
for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} {
if strings.HasPrefix(e, gone) {
t.Fatalf("an event no longer said was said: %s", e)
}
}
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
t.Fatalf("an event carries a secret: %s", e)
}
@@ -0,0 +1,50 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare`
// step — and asks for each piece of its kept data by template.
func TestTheManifestKeepsNoOldForm(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, old := range []string{"reads", "state"} {
if _, ok := m[old]; ok {
t.Errorf("the manifest keeps the old section %q", old)
}
}
var resources []struct {
ID string `json:"id"`
}
_ = json.Unmarshal(m["resources"], &resources)
for _, r := range resources {
if r.ID == "prepare" {
t.Error("the manifest keeps the run-once step prepare")
}
}
var requires []json.RawMessage
_ = json.Unmarshal(m["requires"], &requires)
kept := map[string]string{}
for _, r := range requires {
var e struct{ Name, Template string }
if json.Unmarshal(r, &e) == nil {
kept[e.Name] = e.Template
}
}
for name, template := range map[string]string{"licences": "state", "bindings": "state", "leases": "state",
"offered": "state", "usage": "log", "audit": "log", "carried": "state", "conditions": "state"} {
if kept[name] != template {
t.Errorf("bus-state entry %s is %q, want template %s", name, kept[name], template)
}
}
}
@@ -1,71 +1,27 @@
package main
// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this
// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each
// statement idempotent.
// Today's database, read once by the carry-over and never written (design 39 bus-and-switch §1, §11, A3): the
// module's own, opened with the credential the mesh still provisions for it alone. Every query is a select; a table
// that does not exist (a fresh install) is read as no rows.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Schema is what this version needs.
var Schema = []string{
`create table if not exists licence (
name text primary key,
kind text not null check (kind in ('subscription', 'api-key')),
account_uuid text unique,
email text,
organization_uuid text,
sealed text,
refresh_fingerprint text,
access_expires_at bigint,
refresh_expires_at bigint,
failures integer not null default 0,
notified_at bigint,
adopted_at bigint not null,
rotated_at bigint)`,
`create sequence if not exists binding_generation`,
`create table if not exists binding (
consumer text primary key,
licence text not null references licence(name),
generation bigint not null)`,
`create table if not exists lease (
key text primary key,
holder text not null,
until timestamptz not null)`,
`create table if not exists offered (
fingerprint text primary key,
node text not null,
account_uuid text,
outcome text not null,
why text not null,
at timestamptz not null default now())`,
`create table if not exists usage (
licence text not null,
at bigint not null,
reading jsonb not null,
raw jsonb not null)`,
`create index if not exists usage_by_licence on usage (licence, at desc)`,
`create table if not exists audit (
at timestamptz not null default now(),
what text not null,
detail jsonb not null)`,
}
// PgSource reads today's database.
type PgSource struct{ pool *pgxpool.Pool }
// PgStore is the store on postgres.
type PgStore struct{ pool *pgxpool.Pool }
// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
// PgSourceFromEnv opens the database the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
func PgSourceFromEnv(ctx context.Context) (*PgSource, error) {
file := os.Getenv("DATABASE_URL_FILE")
if file == "" {
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
@@ -74,218 +30,132 @@ func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
if err != nil {
return nil, err
}
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
return OpenPgSource(ctx, strings.TrimSpace(string(raw)))
}
// OpenPgStore opens a store at a URL.
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
// OpenPgSource opens a database at a URL, and reaches it once, so an unreachable one fails here.
func OpenPgSource(ctx context.Context, url string) (*PgSource, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, err
}
return &PgStore{pool: pool}, nil
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &PgSource{pool: pool}, nil
}
// Migrate brings the schema to this version's shape.
func (s *PgStore) Migrate(ctx context.Context) error {
for _, q := range Schema {
if _, err := s.pool.Exec(ctx, q); err != nil {
return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err)
// Close lets the database go.
func (s *PgSource) Close() { s.pool.Close() }
// noTable is whether err is postgres saying a table does not exist (SQLSTATE 42P01).
func noTable(err error) bool {
var pg *pgconn.PgError
return errors.As(err, &pg) && pg.Code == "42P01"
}
// each runs a select and scans each row; a table that does not exist is no rows.
func (s *PgSource) each(ctx context.Context, q string, scan func(pgx.Rows) error, args ...any) error {
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
if noTable(err) {
return nil
}
return err
}
defer rows.Close()
for rows.Next() {
if err := scan(rows); err != nil {
return err
}
}
if err := rows.Err(); err != nil && !noTable(err) {
return err
}
return nil
}
const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0),
failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)`
func scanLicence(row pgx.Row) (*Licence, error) {
var l Licence
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return &l, err
func (s *PgSource) Licences(ctx context.Context) ([]LicenceRow, error) {
out := []LicenceRow{}
err := s.each(ctx, `select name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0),
coalesce(refresh_expires_at,0), failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)
from licence order by adopted_at, name`, func(r pgx.Rows) error {
var l LicenceRow
err := r.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
out = append(out, l)
return err
})
return out, err
}
func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) {
rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Licence
for rows.Next() {
l, err := scanLicence(rows)
if err != nil {
return nil, err
func (s *PgSource) Bindings(ctx context.Context) ([]Binding, error) {
out := []Binding{}
err := s.each(ctx, `select consumer, licence, generation from binding order by generation, consumer`,
func(r pgx.Rows) error {
var b Binding
err := r.Scan(&b.Consumer, &b.Licence, &b.Generation)
out = append(out, b)
return err
})
return out, err
}
func (s *PgSource) Leases(ctx context.Context) ([]LeaseRow, error) {
out := []LeaseRow{}
err := s.each(ctx, `select key, holder, until from lease order by key`, func(r pgx.Rows) error {
var l LeaseRow
err := r.Scan(&l.Key, &l.Holder, &l.Until)
out = append(out, l)
return err
})
return out, err
}
func (s *PgSource) Offered(ctx context.Context) ([]OfferedRow, error) {
out := []OfferedRow{}
err := s.each(ctx, `select fingerprint, node, coalesce(account_uuid,''), outcome, why, at from offered
order by at, fingerprint`, func(r pgx.Rows) error {
var o OfferedRow
var outcome string
err := r.Scan(&o.Fingerprint, &o.Node, &o.Account, &outcome, &o.Why, &o.At)
o.Outcome = Outcome(outcome)
out = append(out, o)
return err
})
return out, err
}
func (s *PgSource) Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) {
out := []UsageTableRow{}
err := s.each(ctx, `select licence, at, reading, raw from usage where at >= $1 order by at, licence`,
func(r pgx.Rows) error {
var u UsageTableRow
var reading, raw []byte
if err := r.Scan(&u.Licence, &u.At, &reading, &raw); err != nil {
return err
}
_ = json.Unmarshal(reading, &u.Reading)
_ = json.Unmarshal(raw, &u.Raw)
out = append(out, u)
return nil
}, since.UnixMilli())
return out, err
}
func (s *PgSource) Audit(ctx context.Context) ([]AuditRow, error) {
out := []AuditRow{}
err := s.each(ctx, `select at, what, detail from audit order by at`, func(r pgx.Rows) error {
var a AuditRow
var detail []byte
if err := r.Scan(&a.At, &a.What, &detail); err != nil {
return err
}
out = append(out, *l)
}
return out, rows.Err()
}
func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) {
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name))
}
func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account))
}
func nullable(s string) any {
if s == "" {
_ = json.Unmarshal(detail, &a.Detail)
out = append(out, a)
return nil
}
return s
})
return out, err
}
func nullableInt(v int64) any {
if v == 0 {
return nil
}
return v
}
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
rotated_at = excluded.rotated_at`,
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
return err
}
// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds
// it live and does nothing.
func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) {
tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3))
on conflict (key) do update set holder = excluded.holder, until = excluded.until
where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds())
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *PgStore) Unlease(ctx context.Context, key, holder string) error {
_, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder)
return err
}
func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) {
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Binding
for rows.Next() {
var b Binding
if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) {
return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`)
}
func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) {
out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer)
if err != nil || len(out) == 0 {
return nil, err
}
return &out[0], nil
}
func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) {
out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation'))
on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation
returning consumer, licence, generation`, consumer, licence)
if err != nil {
return Binding{}, err
}
return out[0], nil
}
func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer)
return err == nil && tag.RowsAffected() == 1, err
}
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
if generation <= 0 {
return nil
}
// setval with is_called, so the next nextval is generation+1; only ever forward.
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
where not is_called or last_value < $1`, generation)
return err
}
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
returning consumer, licence, generation`, licence)
}
func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) {
var o string
err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return Outcome(o), err
}
func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error {
_, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5)
on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`,
fp, node, nullable(account), string(o), why)
return err
}
func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
reading, _ := json.Marshal(r)
rawJSON, _ := json.Marshal(raw)
_, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON)
return err
}
func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []UsageRow
for rows.Next() {
var u UsageRow
var reading []byte
if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil {
return nil, err
}
_ = json.Unmarshal(reading, &u.Reading)
out = append(out, u)
}
return out, rows.Err()
}
func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error {
raw, _ := json.Marshal(detail)
_, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw)
return err
}
func (s *PgStore) Close() { s.pool.Close() }
@@ -0,0 +1,26 @@
package main
// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in
// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known,
// so it is never a target.
import (
"math"
"time"
)
// Room is a licence's room at now, and whether it is known.
func Room(l Licence, now time.Time, s Settings) (float64, bool) {
if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil {
return 0, false
}
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
if err != nil || now.Sub(read) > s.UsageStale {
return 0, false
}
if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) {
return 0, false
}
return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true
}
@@ -0,0 +1,128 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// vendorUsage is the vendor's usage answer as it reads: three windows, each with its reset time.
var vendorUsage = map[string]any{
"five_hour": map[string]any{"utilization": 41.0, "resets_at": "2026-11-11T23:00:00Z"},
"seven_day": map[string]any{"utilization": 97.0, "resets_at": "2026-11-14T09:00:00Z"},
"seven_day_sonnet": map[string]any{"utilization": 12.0, "resets_at": "2026-11-15T09:00:00Z"},
}
// design 39 bus-and-switch §24/AC1: a vendor answer is flattened with its three reset times, kept beside the licence
// with when it was read, and said as usage.read with those fields.
func TestAReadingIsFlattenedWithThreeResets(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
mm.vendor.usage = vendorUsage
mm.events = nil
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
l, _ := mm.store.Licence(ctx, licence1)
u := l.Usage
if u == nil || *u.SessionPct != 41 || u.SessionResetsAt != "2026-11-11T23:00:00Z" || *u.WeeklyPct != 97 ||
u.WeeklyResetsAt != "2026-11-14T09:00:00Z" || *u.SonnetPct != 12 || u.SonnetResetsAt != "2026-11-15T09:00:00Z" ||
u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || u.ReadError != "" {
t.Fatalf("kept %+v", u)
}
said := strings.Join(mm.events, "\n")
for _, want := range []string{`"weeklyResetsAt":"2026-11-14T09:00:00Z"`, `"sonnetResetsAt":"2026-11-15T09:00:00Z"`,
`"sessionResetsAt":"2026-11-11T23:00:00Z"`} {
if !strings.Contains(said, "usage.read ") || !strings.Contains(said, want) {
t.Fatalf("said %s, lacking %s", said, want)
}
}
}
// design 39 bus-and-switch §24/AC2: a failed answer keeps the last percentages and their time, writes the vendor's
// status and reason, and appends no reading.
func TestAFailedReadingKeepsThePercentages(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
mm.vendor.usage = vendorUsage
_ = mm.m.ReadUsage(ctx)
mm.now = t0.Add(5 * time.Minute)
mm.vendor.usageErr = errors.New(`the usage endpoint answered 429: {"error":"rate_limited"}`)
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
l, _ := mm.store.Licence(ctx, licence1)
u := l.Usage
if u == nil || *u.WeeklyPct != 97 || u.ReadAt != t0.UTC().Format(time.RFC3339Nano) ||
u.ReadError != `the usage endpoint answered 429: {"error":"rate_limited"}` {
t.Fatalf("kept %+v", u)
}
if n := len(mm.usage.entries); n != 1 {
t.Fatalf("a failed reading appended: %d entries", n)
}
// §16, §31: a grant the vault does not answer is a failed reading, written and gone past, never the end of the round.
mm.vendor.usageErr = nil
mm.grants.mu.Lock()
delete(mm.grants.held, SecretName(KeyFor(licence1), PartGrant))
mm.grants.mu.Unlock()
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatalf("a grant the vault did not answer ended the round: %v", err)
}
l, _ = mm.store.Licence(ctx, licence1)
if !strings.Contains(l.Usage.ReadError, "cannot be read from the vault") || *l.Usage.WeeklyPct != 97 {
t.Fatalf("after the vault's refusal the reading is %+v", l.Usage)
}
// §4, §16: a reading is taken under the licence's lease; with another run holding it, nothing is read or written.
if ok, _ := mm.store.Lease(ctx, "licence."+KeyFor(licence1), "another run", time.Minute); !ok {
t.Fatal("the lease was not taken")
}
reads, err := mm.m.ReadUsageOf(ctx, licence1, true)
if err != nil || len(reads) != 1 || reads[0].Error != "another run holds its lease" {
t.Fatalf("a reading under another's lease answered %+v, %v", reads, err)
}
if again, _ := mm.store.Licence(ctx, licence1); again.Usage.ReadError != l.Usage.ReadError {
t.Fatalf("a reading under another's lease wrote %+v", again.Usage)
}
}
// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives.
func TestTheRoomOfEachLicence(t *testing.T) {
now := t0
pct := func(v float64) *float64 { return &v }
read := func(session, weekly float64, ago time.Duration) *UsageReading {
return &UsageReading{SessionPct: pct(session), WeeklyPct: pct(weekly), ReadAt: now.Add(-ago).Format(time.RFC3339Nano)}
}
for _, c := range []struct {
name string
l Licence
room float64
known bool
}{
{"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
{"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true},
{"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true},
{"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false},
{"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading {
u := read(10, 30, 5*time.Minute)
u.ReadError = "the usage endpoint answered 500: x"
return u
}()}, 70, true},
{"never read", Licence{Kind: "subscription"}, 0, false},
{"no weekly window", Licence{Kind: "subscription", Usage: &UsageReading{SessionPct: pct(10),
ReadAt: now.Format(time.RFC3339Nano)}}, 0, false},
{"refresh failing", Licence{Kind: "subscription", Failures: 3, Usage: read(10, 10, time.Minute)}, 0, false},
{"refresh failed twice", Licence{Kind: "subscription", Failures: 2, Usage: read(10, 10, time.Minute)}, 90, true},
{"refresh token expired", Licence{Kind: "subscription", RefreshExpiresAt: now.UnixMilli(),
Usage: read(10, 10, time.Minute)}, 0, false},
{"an API key", Licence{Kind: "api-key", Usage: read(0, 0, 0)}, 0, false},
} {
room, known := Room(c.l, now, Defaults)
if room != c.room || known != c.known {
t.Errorf("%s: room %v known %v, want %v %v", c.name, room, known, c.room, c.known)
}
}
}
@@ -0,0 +1,174 @@
package main
// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each
// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the
// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a
// value read is written in the audit under `settings`.
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"time"
)
// Settings are the manager's own, declared with defaults.
type Settings struct {
Cadence time.Duration // rotate a grant once older than this
Floor time.Duration // refresh in any case with less than this left
FailuresToNotify int
Cooldown time.Duration // at most one notification per licence in this window
RefreshWarn time.Duration // warn this long before a refresh token itself expires
// SwitchAuto is whether a reading at its threshold moves the licence's consumers (§26).
SwitchAuto bool
// SwitchAt, RoomBelow and WarnAt are percentages: where a licence's consumers move, what a target's windows must
// both be below, and where a licence with consumers is warned about.
SwitchAt, RoomBelow, WarnAt float64
// UsageEvery is how often each subscription's usage is read.
UsageEvery time.Duration
// UsageStale is how old a licence's latest successful reading may be for its room to be known (§25).
UsageStale time.Duration
// OutOfBound is every setting the file sets outside the bound the manifest declares, kept at its default and said
// as settings.<setting>.invalid until it is fixed (B37).
OutOfBound []OutOfBound
}
// OutOfBound is one setting set outside its bound: the value set, the bound and the default kept in its place.
type OutOfBound struct {
Setting string
Value float64
Min, Max float64
Default float64
HasMax bool
}
// Defaults are the settings a fresh mesh runs with.
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour,
RefreshWarn: 72 * time.Hour, SwitchAuto: true, SwitchAt: 99, RoomBelow: 90, WarnAt: 90, UsageEvery: 5 * time.Minute,
UsageStale: 15 * time.Minute}
// SettingsFrom reads the settings file. Today's five keep a default for anything absent or not positive; the six of
// the switch keep a default for anything absent or outside the bound the manifest declares (§33).
func SettingsFrom(path string) Settings {
s := Defaults
raw, err := os.ReadFile(path)
if err != nil {
return s
}
var f map[string]any
if json.Unmarshal(raw, &f) != nil {
return s
}
num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok }
// below is a value under the manifest's minimum of 0 for today's five: kept at its default and said (B37).
below := func(k string, v float64, def float64) {
if v < 0 {
s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Default: def})
}
}
set := func(k string, unit time.Duration, into *time.Duration) {
v, ok := num(k)
if ok && v > 0 {
*into = time.Duration(v * float64(unit))
} else if ok {
below(k, v, float64(*into)/float64(unit))
}
}
set("cadence_minutes", time.Minute, &s.Cadence)
set("floor_minutes", time.Minute, &s.Floor)
set("cooldown_hours", time.Hour, &s.Cooldown)
set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn)
if v, ok := num("failures_to_notify"); ok && v > 0 {
s.FailuresToNotify = int(v)
} else if ok {
below("failures_to_notify", v, float64(s.FailuresToNotify))
}
if v, ok := f["switch_auto"].(bool); ok {
s.SwitchAuto = v
}
bounded := func(k string, lo, hi float64) (float64, bool) {
v, ok := num(k)
if ok && (v < lo || v > hi) {
def, _ := Defaults.Values()[k].(float64)
s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Min: lo, Max: hi, Default: def, HasMax: true})
}
return v, ok && v >= lo && v <= hi
}
for _, p := range []struct {
key string
into *float64
}{{"switch_at_pct", &s.SwitchAt}, {"room_below_pct", &s.RoomBelow}, {"warn_at_pct", &s.WarnAt}} {
if v, ok := bounded(p.key, 1, 100); ok {
*p.into = v
}
}
if v, ok := bounded("usage_minutes", 1, 60); ok {
s.UsageEvery = time.Duration(v * float64(time.Minute))
}
if v, ok := bounded("usage_stale_minutes", 5, 180); ok {
s.UsageStale = time.Duration(v * float64(time.Minute))
}
return s
}
// Values are the settings by their names in the manifest's `config`, in its units.
func (s Settings) Values() map[string]any {
return map[string]any{"cadence_minutes": s.Cadence.Minutes(), "floor_minutes": s.Floor.Minutes(),
"failures_to_notify": float64(s.FailuresToNotify), "cooldown_hours": s.Cooldown.Hours(),
"refresh_warn_days": s.RefreshWarn.Hours() / 24, "switch_auto": s.SwitchAuto, "switch_at_pct": s.SwitchAt,
"room_below_pct": s.RoomBelow, "warn_at_pct": s.WarnAt, "usage_minutes": s.UsageEvery.Minutes(),
"usage_stale_minutes": s.UsageStale.Minutes()}
}
// Invalid is a setting above switch_at_pct, which moves no consumer until it is fixed (§33): the setting and both
// values.
type Invalid struct {
Setting string
Value float64
SwitchAt float64
}
// Invalid is every setting of the pairs §33 names that is above switch_at_pct: room_below_pct, then warn_at_pct.
func (s Settings) Invalid() []Invalid {
var out []Invalid
if s.RoomBelow > s.SwitchAt {
out = append(out, Invalid{Setting: "room_below_pct", Value: s.RoomBelow, SwitchAt: s.SwitchAt})
}
if s.WarnAt > s.SwitchAt {
out = append(out, Invalid{Setting: "warn_at_pct", Value: s.WarnAt, SwitchAt: s.SwitchAt})
}
return out
}
// settings are those in force.
func (m *Manager) settings() Settings {
m.settingsMu.RLock()
defer m.settingsMu.RUnlock()
return m.Settings
}
// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit
// under `settings`, its value before and now, and the settings read are those in force from then on.
func (m *Manager) ReadSettings(ctx context.Context) error {
if m.SettingsFile == "" {
return nil
}
next := SettingsFrom(m.SettingsFile)
m.settingsMu.Lock()
before := m.Settings
m.Settings = next
m.settingsMu.Unlock()
was, now := before.Values(), next.Values()
changed := map[string]any{}
for k, v := range now {
if !reflect.DeepEqual(was[k], v) {
changed[k] = map[string]any{"from": was[k], "to": v}
}
}
if len(changed) == 0 {
return nil
}
return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed)
}
@@ -0,0 +1,99 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
)
// design 39 bus-and-switch §33/AC1: each setting's default is the manifest's, a value at its bound is taken, and one
// outside its bound or absent keeps the default.
func TestEachSettingsDefaultAndBound(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Config map[string]struct {
Default any `json:"default"`
Minimum *float64 `json:"minimum"`
Maximum *float64 `json:"maximum"`
Trusted bool `json:"trusted"`
} `json:"config"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
defaults := Defaults.Values()
if len(m.Config) != len(defaults) {
t.Fatalf("the manifest declares %d settings, the code %d", len(m.Config), len(defaults))
}
file := filepath.Join(t.TempDir(), "settings.json")
read := func(k string, v any) any {
b, _ := json.Marshal(map[string]any{k: v})
_ = os.WriteFile(file, b, 0o600)
return SettingsFrom(file).Values()[k]
}
for k, c := range m.Config {
if !c.Trusted || c.Default != defaults[k] {
t.Errorf("%s: the manifest's default %v (trusted %v), the code's %v", k, c.Default, c.Trusted, defaults[k])
}
if c.Maximum == nil {
continue
}
lo, hi := *c.Minimum, *c.Maximum
for v, want := range map[float64]any{lo: lo, hi: hi, lo - 1: defaults[k], hi + 1: defaults[k]} {
if got := read(k, v); got != want {
t.Errorf("%s set to %v reads %v, want %v", k, v, got, want)
}
}
}
if read("switch_auto", false) != false || read("switch_at_pct", "high") != 99.0 {
t.Error("switch_auto off, or a value of the wrong type, is not read as written")
}
}
// design 39 bus-and-switch §33: room_below_pct or warn_at_pct above switch_at_pct is an invalid pair naming both
// values; equal is valid.
func TestAnInvalidPairIsFound(t *testing.T) {
s := Defaults
if len(s.Invalid()) != 0 {
t.Fatal("the defaults are invalid")
}
s.SwitchAt, s.RoomBelow, s.WarnAt = 80, 85, 80
got := s.Invalid()
if len(got) != 1 || got[0] != (Invalid{Setting: "room_below_pct", Value: 85, SwitchAt: 80}) {
t.Fatalf("invalid %+v", got)
}
s.WarnAt = 95
if got := s.Invalid(); len(got) != 2 || got[1].Setting != "warn_at_pct" {
t.Fatalf("invalid %+v", got)
}
}
// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is
// written once in the audit under settings.
func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600)
if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 {
t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err)
}
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600)
_ = mm.m.ReadUsage(ctx)
_ = mm.m.ReadUsage(ctx)
if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 {
t.Fatalf("in force %+v", s)
}
got := mm.audit.under("settings")
if len(got) != 1 {
t.Fatalf("audited %v", got)
}
if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 {
t.Fatalf("audited %v", got[0])
}
}
@@ -1,39 +1,63 @@
package main
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
// with a generation, what became of each login it was offered, usage readings, and the audit. One
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
// rule is tested without a database, and the database is asked only to keep rows.
// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation,
// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage
// (busstore.go). Every rule is tested on the same store over a bus kept in memory.
import (
"context"
"sort"
"sync"
"time"
)
// Licence is one licence: an account, or an API key.
// Licence is one licence: an account, or an API key. Its form is the value of the state `licences` (design 39
// bus-and-switch §2): never a secret, which the vault holds (§15).
type Licence struct {
Name string `json:"name"`
Kind string `json:"kind"` // subscription | api-key
AccountUUID string `json:"accountUuid,omitempty"`
Email string `json:"email,omitempty"`
OrganizationUUID string `json:"organizationUuid,omitempty"`
Sealed string `json:"-"` // the grant or the key, encrypted at rest
RefreshFingerprint string `json:"-"`
AccountUUID string `json:"account,omitempty"`
Email string `json:"address,omitempty"`
OrganizationUUID string `json:"organisation,omitempty"`
RefreshFingerprint string `json:"refreshFingerprint,omitempty"`
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
Failures int `json:"failures"`
NotifiedAt int64 `json:"-"`
NotifiedAt int64 `json:"notifiedAt,omitempty"`
AdoptedAt int64 `json:"adoptedAt"`
AdoptedFrom string `json:"adoptedFrom,omitempty"`
RotatedAt int64 `json:"rotatedAt,omitempty"`
// Generation is the generation of the licence's secrets in the vault (§2, §15).
Generation int64 `json:"generation,omitempty"`
// Usage is the latest reading of the licence's usage (§2, §24).
Usage *UsageReading `json:"usage,omitempty"`
}
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
// Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and
// why and by whom it was moved last.
type Binding struct {
Consumer string `json:"consumer"`
Licence string `json:"licence"`
Consumer string `json:"consumer"`
Licence string `json:"licence"`
// Key is the licence's key (§10), answered by the verb bindings (B25); the store keeps none.
Key string `json:"key,omitempty"`
Kind string `json:"kind,omitempty"`
Generation int64 `json:"generation"`
Since string `json:"since,omitempty"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Move is what moves a binding: the licence's kind, why and by whom, and the floor a consumer's generation must pass
// (B11: the generation its node last reported); a nil Floor is none.
type Move struct {
Kind, Why, By string
Floor func(consumer string) int64
}
// floor is the floor m gives consumer.
func (m Move) floor(consumer string) int64 {
if m.Floor == nil {
return 0
}
return m.Floor(consumer)
}
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
@@ -52,6 +76,8 @@ type UsageRow struct {
Licence string `json:"licence"`
At int64 `json:"at"`
Reading UsageReading `json:"reading"`
// Raw is the vendor's answer whole, answered only when asked (§35).
Raw map[string]any `json:"raw,omitempty"`
}
// Store is what the manager keeps.
@@ -60,213 +86,22 @@ type Store interface {
Licence(ctx context.Context, name string) (*Licence, error)
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
SaveLicence(ctx context.Context, l Licence) error
Bindings(ctx context.Context) ([]Binding, error)
Binding(ctx context.Context, consumer string) (*Binding, error)
// Bind binds (or switches) a consumer at its next generation.
Bind(ctx context.Context, consumer, licence string, m Move) (Binding, error)
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence its next generation: what a rotation is to them.
Advance(ctx context.Context, licence string, m Move) ([]Binding, error)
// Lease takes a lease for d, or answers false while another holder's is live.
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
Unlease(ctx context.Context, key, holder string) error
Bindings(ctx context.Context) ([]Binding, error)
Binding(ctx context.Context, consumer string) (*Binding, error)
// Bind binds (or switches) a consumer at the next generation.
Bind(ctx context.Context, consumer, licence string) (Binding, error)
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
Advance(ctx context.Context, licence string) ([]Binding, error)
// GenerationsAbove makes every generation given from now on greater than this one.
GenerationsAbove(ctx context.Context, generation int64) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
// Usage is the latest readings, newest first, of one licence or every one: at most limit, none before since (zero
// is none), the vendor's answer with raw (§35).
Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error)
// Audit appends one entry for an act; an error is the act failing (B20).
Audit(ctx context.Context, what string, detail map[string]any) error
Close()
}
// MemoryStore is the tests' store.
type MemoryStore struct {
mu sync.Mutex
now func() time.Time
rows map[string]Licence
binds map[string]Binding
leases map[string]struct {
holder string
until time.Time
}
outcomes map[string]Outcome
usage []UsageRow
Audits []map[string]any
generation int64
}
// NewMemoryStore is an empty store whose leases age by now.
func NewMemoryStore(now func() time.Time) *MemoryStore {
return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{},
leases: map[string]struct {
holder string
until time.Time
}{}, outcomes: map[string]Outcome{}}
}
func (m *MemoryStore) Licences(context.Context) ([]Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Licence, 0, len(m.rows))
for _, l := range m.rows {
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
if l, ok := m.rows[name]; ok {
return &l, nil
}
return nil, nil
}
func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, l := range m.rows {
if l.AccountUUID == account {
return &l, nil
}
}
return nil, nil
}
func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[l.Name] = l
return nil
}
func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder {
return false, nil
}
m.leases[key] = struct {
holder string
until time.Time
}{holder, m.now().Add(d)}
return true, nil
}
func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.leases[key].holder == holder {
delete(m.leases, key)
}
return nil
}
func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Binding, 0, len(m.binds))
for _, b := range m.binds {
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
if b, ok := m.binds[consumer]; ok {
return &b, nil
}
return nil, nil
}
func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.generation++
b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation}
m.binds[consumer] = b
return b, nil
}
func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.binds[consumer]
delete(m.binds, consumer)
return ok, nil
}
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.generation = max(m.generation, generation)
return nil
}
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []Binding
for c, b := range m.binds {
if b.Licence != licence {
continue
}
m.generation++
b.Generation = m.generation
m.binds[c] = b
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.outcomes[fp], nil
}
func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.outcomes[fp] = o
return nil
}
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
return nil
}
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []UsageRow
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
if licence == "" || m.usage[i].Licence == licence {
out = append(out, m.usage[i])
}
}
return out, nil
}
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
d := map[string]any{"what": what}
for k, v := range detail {
d[k] = v
}
m.Audits = append(m.Audits, d)
return nil
}
func (m *MemoryStore) Close() {}
@@ -1,95 +1,12 @@
package main
import (
"context"
"os"
"crypto/rand"
"encoding/base64"
"strings"
"testing"
"time"
)
// Against a real postgres, because the questions are the database's: does the schema apply twice, does a
// lease refuse a second holder, does a generation only grow. Skipped unless one is named:
//
// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
func TestTheStoreOnPostgres(t *testing.T) {
url := os.Getenv("MESH_TEST_POSTGRES")
if url == "" {
t.Skip("MESH_TEST_POSTGRES unset")
}
ctx := context.Background()
s, err := OpenPgStore(ctx, url)
if err != nil {
t.Fatal(err)
}
defer s.Close()
for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} {
_, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade")
}
_, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation")
for i := 0; i < 2; i++ {
if err := s.Migrate(ctx); err != nil {
t.Fatalf("migration %d: %v", i+1, err)
}
}
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
if err := s.SaveLicence(ctx, l); err != nil {
t.Fatal(err)
}
l.Failures = 2
_ = s.SaveLicence(ctx, l)
if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" {
t.Fatalf("%+v", got)
}
if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil {
t.Fatalf("%v %v", none, err)
}
lease := func(holder string) bool {
ok, err := s.Lease(ctx, "licence:a", holder, time.Minute)
if err != nil {
t.Fatal(err)
}
return ok
}
if !lease("one") || lease("two") || !lease("one") {
t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it")
}
_ = s.Unlease(ctx, "licence:a", "one")
if !lease("two") {
t.Fatal("a released lease was not taken")
}
b1, _ := s.Bind(ctx, "laptop", l.Name)
adv, _ := s.Advance(ctx, l.Name)
b3, _ := s.Bind(ctx, "laptop", l.Name)
if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) {
t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation)
}
_ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400")
if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead {
t.Fatalf("outcome %q", o)
}
if o, _ := s.Outcome(ctx, "sha256:none"); o != "" {
t.Fatalf("an unknown login is %q", o)
}
pct := 1.0
_ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{})
if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 {
t.Fatalf("usage %v", u)
}
if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil {
t.Fatal(err)
}
if ok, _ := s.Unbind(ctx, "laptop"); !ok {
t.Fatal("unbind")
}
all, _ := s.Licences(ctx)
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
t.Fatalf("%v", all)
}
}
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
in := int64(60)
@@ -109,7 +26,7 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
a, _ := NewCrypt("one key")
b, _ := NewCrypt("another key")
sealed := a.Seal(`{"refreshToken":"rt"}`)
sealed := sealAtRest(t, a, `{"refreshToken":"rt"}`)
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
if strings.Contains(sealed, "refreshToken") {
t.Fatal("stored in the clear")
@@ -124,3 +41,16 @@ func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
t.Fatal("an empty key was accepted")
}
}
// sealAtRest seals a grant as today's database keeps it, `v1.<iv>.<ciphertext and tag>`: the rows the carry-over reads.
func sealAtRest(t *testing.T, c *Crypt, plaintext string) string {
t.Helper()
gcm, err := newGCM(c.key)
if err != nil {
t.Fatal(err)
}
iv := make([]byte, 12)
_, _ = rand.Read(iv)
b64 := base64.StdEncoding.EncodeToString
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
}
@@ -0,0 +1,188 @@
package main
// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and
// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every
// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below
// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off
// a licence at its threshold, so none flaps between two.
import (
"context"
"fmt"
"sort"
"strings"
)
// ByUsage is who moves a consumer by the rule (§26).
const ByUsage = "usage"
// Switch is one act of the rule: from L to the target, the consumers it moves, the window of L that reached its
// threshold, its percentage and reset, and the target's room.
type Switch struct {
From string `json:"from"`
To string `json:"to"`
Consumers []string `json:"consumers"`
Window string `json:"window"`
Pct float64 `json:"pct"`
ResetsAt string `json:"resetsAt"`
Room float64 `json:"room"`
}
// why is the cause a moved binding carries: L, the window, its percentage and its reset (§26).
func (s Switch) why() string {
return fmt.Sprintf("%s reached %g %% of its %s window, which resets at %s", s.From, s.Pct, s.Window, s.ResetsAt)
}
// fields are the act's fields, the same in licence.switched and the audit entry switched (§27).
func (s Switch) fields() map[string]any {
f := switchedFields(s.From, s.To, s.Consumers, Cause{Why: s.why(), By: ByUsage})
f["window"], f["pct"], f["resetsAt"], f["room"] = s.Window, s.Pct, s.ResetsAt, s.Room
return f
}
// Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence
// has room; Over is then the licence's consumers and the window at its threshold, for the condition no-room (§29).
type Rule struct {
Switch *Switch
NoTarget bool
Over *Switch
}
// consumersOf are the consumers bound to licence, sorted.
func consumersOf(licence string, bindings []Binding) []string {
var consumers []string
for _, b := range bindings {
if b.Licence == licence {
consumers = append(consumers, b.Consumer)
}
}
sort.Strings(consumers)
return consumers
}
// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name,
// percentage and reset; ok false when neither is.
func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) {
if u == nil {
return "", 0, "", false
}
session, weekly := u.SessionPct, u.WeeklyPct
switch {
case session != nil && *session >= pct && (weekly == nil || *session >= *weekly):
return "session", *session, u.SessionResetsAt, true
case weekly != nil && *weekly >= pct:
return "weekly", *weekly, u.WeeklyResetsAt, true
}
return "", 0, "", false
}
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below
// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room.
func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) {
others := append([]Licence(nil), licences...)
sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name })
to, best := "", -1.0
for _, o := range others {
if o.Name == l.Name {
continue
}
room, known := Room(o, m.Now(), s)
if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
continue
}
best, to = room, o.Name
}
return to, best
}
// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its
// threshold, or with no consumer, gives nothing.
func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule {
consumers := consumersOf(l.Name, bindings)
if len(consumers) == 0 {
return Rule{}
}
sw := Switch{From: l.Name, Consumers: consumers}
var ok bool
if sw.Window, sw.Pct, sw.ResetsAt, ok = windowAt(l.Usage, s.SwitchAt); !ok {
return Rule{}
}
if sw.To, sw.Room = m.targetFor(l, licences, s); sw.To == "" {
sw.Room = 0
return Rule{NoTarget: true, Over: &sw}
}
return Rule{Switch: &sw}
}
// ApplyRule applies §26 to one licence, or to every one when licence is empty: after a reading, by switch_auto, or by
// hand, even with switch_auto off (§32, §39). With dry it answers the moves and moves nothing. An invalid pair of
// settings moves no consumer (§33).
func (m *Manager) ApplyRule(ctx context.Context, licence string, byHand, dry bool) ([]Switch, error) {
s := m.settings()
if (!s.SwitchAuto && !byHand) || len(s.Invalid()) > 0 {
return nil, nil
}
licences, err := m.Store.Licences(ctx)
if err != nil {
return nil, err
}
bindings, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
var acts []Switch
for _, l := range licences {
if licence != "" && l.Name != licence {
continue
}
r := m.RuleFor(l, licences, bindings, s)
if r.Switch == nil {
continue
}
acts = append(acts, *r.Switch)
if dry {
continue
}
if err := m.act(ctx, *r.Switch, licences); err != nil {
return acts, err
}
// What was moved is bound to the target now, for the next licence's rule.
if bindings, err = m.Store.Bindings(ctx); err != nil {
return acts, err
}
}
return acts, nil
}
// act moves every consumer of one act as the switch verb does, then says the act once and tells the operator (§26 to
// §28).
func (m *Manager) act(ctx context.Context, sw Switch, licences []Licence) error {
kind := ""
for _, l := range licences {
if l.Name == sw.To {
kind = l.Kind
}
}
floor := m.floorIn(nil)
for _, consumer := range sw.Consumers {
b, err := m.Store.Bind(ctx, consumer, sw.To, Move{Kind: kind, Why: sw.why(), By: ByUsage, Floor: floor})
if err != nil {
return fmt.Errorf("moving %s to %s: %w", consumer, sw.To, err)
}
m.changed(b, sw.From)
}
f := sw.fields()
if err := m.record(ctx, "switched", fmt.Sprintf("switched %s from %s to %s: %s; by %s",
strings.Join(sw.Consumers, ", "), sw.From, sw.To, sw.why(), ByUsage), f); err != nil {
return err
}
_ = m.Emit("licence.switched", f)
if m.Tell != nil {
line := fmt.Sprintf("%s; %s has %g %% room", sw.why(), sw.To, sw.Room)
if err := m.Tell(ctx, strings.Join(sw.Consumers, ", ")+" moved to "+sw.To, []string{line}); err != nil {
m.Log("the switch of %s to %s could not be told: %v", strings.Join(sw.Consumers, ", "), sw.To, err)
}
}
return nil
}
@@ -0,0 +1,181 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
// read is a licence's latest reading, read at t0.
func read(session, weekly float64) *UsageReading {
return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly,
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
}
func sub(name string, u *UsageReading) Licence {
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
}
// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives.
func TestTheRuleMovesEachTable(t *testing.T) {
mm := newMesh(t)
on := func(consumers ...string) []Binding {
var out []Binding
for _, c := range consumers {
out = append(out, Binding{Consumer: c, Licence: "a"})
}
return out
}
for _, c := range []struct {
name string
licences []Licence
bindings []Binding
to string
window string
noTarget bool
consumers int
}{
{"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0},
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
on("x", "y", "z", "w"), "c", "weekly", false, 4},
{"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1},
{"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))},
on("x"), "b", "session", false, 1},
{"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)),
sub("c", read(89, 89))}, on("x"), "c", "session", false, 1},
{"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)),
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
} {
r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults)
to, window, n := "", "", 0
if r.Switch != nil {
to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers)
}
if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget {
t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget)
}
}
}
// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %.
func fourOnA(t *testing.T) *miniMesh {
mm := newMesh(t)
ctx := context.Background()
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} {
_ = mm.store.SaveLicence(ctx, l)
}
for _, c := range []string{"ace", "g14", "laptop", "server"} {
_, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"})
}
mm.events = nil
return mm
}
// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window,
// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same
// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset.
func TestOneSwitchedPerAct(t *testing.T) {
mm := fourOnA(t)
acts, err := mm.m.ApplyRule(context.Background(), "", false, false)
if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 {
t.Fatalf("acts %+v %v", acts, err)
}
var switched []map[string]any
changed := 0
for _, e := range mm.events {
name, body, _ := strings.Cut(e, " ")
var f map[string]any
_ = json.Unmarshal([]byte(body), &f)
switch name {
case "licence.switched":
switched = append(switched, f)
case "binding.changed":
changed++
}
}
if len(switched) != 1 || changed != 4 {
t.Fatalf("said %v", mm.events)
}
s := switched[0]
if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage ||
s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 {
t.Fatalf("licence.switched %v", s)
}
audit := mm.audit.under("switched")
if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 {
t.Fatalf("audit %v", audit)
}
b := mm.bound("laptop")
if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" {
t.Fatalf("laptop %+v", b)
}
}
// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never.
func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) {
mm := fourOnA(t)
var told []string
mm.m.Tell = func(_ context.Context, headline string, lines []string) error {
told = append(told, headline+" | "+strings.Join(lines, " "))
return nil
}
_, _ = mm.m.ApplyRule(context.Background(), "", false, false)
if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+
"which resets at 2026-11-14T09:00:00Z; b has 70 % room" {
t.Fatalf("told %v", told)
}
_, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
if len(told) != 1 {
t.Fatalf("the switch verb told %v", told)
}
}
// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair
// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off.
func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) {
ctx := context.Background()
mm := fourOnA(t)
dry, err := mm.m.ApplyRule(ctx, "a", true, true)
if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 {
t.Fatalf("dry %+v %v, said %v", dry, err, mm.events)
}
acts, _ := mm.m.ApplyRule(ctx, "a", true, false)
if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") {
t.Fatalf("dry %+v, act %+v", dry, acts)
}
for name, change := range map[string]func(*Settings){
"an invalid pair": func(s *Settings) { s.RoomBelow = 100 },
"switch_auto off": func(s *Settings) { s.SwitchAuto = false },
} {
mm := fourOnA(t)
change(&mm.m.Settings)
if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" {
t.Errorf("%s moved %+v", name, acts)
}
}
mm = fourOnA(t)
mm.m.Settings.SwitchAuto = false
if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" {
t.Fatalf("by hand with switch_auto off moved %+v", acts)
}
}
// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once.
func TestAReadingAtTheThresholdApplies(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage {
t.Fatalf("laptop %+v", b)
}
}
@@ -0,0 +1 @@
{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription","refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":4,"changedAt":"2026-10-04T11:00:00.000Z"}
@@ -0,0 +1,421 @@
package main
// The module's own tools (design 39 bus-and-switch §37 to §44, B40): beside the seat's verbs, they read everything the
// manager keeps and start by hand everything it does by itself. A tool that acts answers "the carry-over has not ended"
// until it has (§11); licences_carried's run is the carry-over itself.
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// tool is one of the module's own tools; acts says whether it acts, so the carry-over's gate holds it (§11).
func tool(name, description string, acts bool, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: name, Description: description, Input: input,
Run: func(a map[string]any) (any, error) {
m, err := manager()
if err != nil {
return nil, err
}
if acts {
if err := m.Carry.Acts(); err != nil {
return nil, err
}
}
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)})
return run(ctx, m, a)
}}
}
func boolean(description string) map[string]any {
return map[string]any{"type": "boolean", "description": description}
}
func number(description string) map[string]any {
return map[string]any{"type": "number", "description": description}
}
func moduleTools() []stdio.Tool {
licence := str("a licence, as `licences` names it; absent for all")
dry := boolean("answer what the rule would do, and do nothing")
return []stdio.Tool{
tool("licences_switches", "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.",
false, map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"),
"since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
l, _ := a["licence"].(string)
c, _ := a["consumer"].(string)
since, err := sinceOf(a)
if err != nil {
return nil, err
}
return m.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000))
}),
tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.",
true, map[string]any{"licence": licence, "dry": dry},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
l, _ := a["licence"].(string)
d, _ := a["dry"].(bool)
return m.ReadUsageOf(ctx, strings.TrimSpace(l), d)
}),
tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.",
true, map[string]any{"licence": licence, "dry": dry},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
l, _ := a["licence"].(string)
d, _ := a["dry"].(bool)
acts, err := m.ApplyRule(ctx, strings.TrimSpace(l), true, d)
if acts == nil {
acts = []Switch{}
}
return acts, err
}),
tool("licences_settings", "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.",
false, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) {
return m.SettingsInForce(ctx)
}),
tool("licences_kept", "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.",
false, map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"),
"key": str("a key of a state, or a subject of a log")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
e, _ := a["entry"].(string)
k, _ := a["key"].(string)
return m.Kept(strings.TrimSpace(e), strings.TrimSpace(k))
}),
tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.",
true, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) {
return m.Resync(ctx)
}),
tool("licences_conditions", "The conditions the manager said and holds: each key of its state conditions with its body and since.",
false, nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
return m.KeptConditions()
}),
tool("licences_carried", "The carry-over's record (when, and per table the rows read and entries written), or, while it has not ended, the failure it names; with run and no record, carry over again. Refused once carried.",
false, map[string]any{"run": boolean("carry over again: only while it has not ended")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
run, _ := a["run"].(bool)
return m.Carried(ctx, run)
}),
}
}
// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before
// since, at most limit (§37).
func (m *Manager) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) {
bus, ok := m.Store.(*BusStore)
if !ok {
return nil, errors.New("the audit is not kept on the bus")
}
info, err := bus.AuditLog.Info()
if err != nil || info.Last == 0 {
return []map[string]any{}, err
}
var all []stdio.LogEntry
for next := uint64(1); next != 0; {
page, err := bus.AuditLog.Read(next, "switched", 0)
if err != nil {
return nil, err
}
all = append(all, page.Entries...)
next = page.Next
}
out := []map[string]any{}
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
var e map[string]any
if json.Unmarshal(all[i].Value, &e) != nil {
continue
}
at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"]))
if at.Before(since) {
continue
}
if licence != "" && e["from"] != licence && e["to"] != licence {
continue
}
consumers, _ := e["consumers"].([]any)
if consumer != "" && !slices.Contains(consumers, any(consumer)) {
continue
}
s := map[string]any{"at": e["at"], "from": e["from"], "to": e["to"], "consumers": e["consumers"],
"by": e["by"], "why": e["why"]}
if _, ok := e["window"]; ok {
s["reading"] = map[string]any{"window": e["window"], "pct": e["pct"], "resetsAt": e["resetsAt"],
"room": e["room"]}
}
out = append(out, s)
}
return out, nil
}
// SettingAnswer is one setting as licences_settings answers it (§40).
type SettingAnswer struct {
Setting string `json:"setting"`
Value any `json:"value"`
Default any `json:"default"`
Level string `json:"level"`
Invalid string `json:"invalid,omitempty"`
}
// SettingsInForce is each setting in force, its default, where mesh-config says it was set, and why it is invalid
// when it is (§40, B18).
func (m *Manager) SettingsInForce(ctx context.Context) ([]SettingAnswer, error) {
levels := map[string]string{}
if m.ConfigLevels != nil {
got, err := m.ConfigLevels(ctx)
if err != nil {
return nil, fmt.Errorf("mesh-config did not say where each setting was set: %w", err)
}
levels = got
}
s := m.settings()
held, err := m.Judge(ctx)
if err != nil {
return nil, err
}
values, defaults := s.Values(), Defaults.Values()
var out []SettingAnswer
for k, v := range values {
a := SettingAnswer{Setting: k, Value: v, Default: defaults[k], Level: levels[k]}
if a.Level == "" {
a.Level = "default"
}
if c, ok := held["settings."+k+".invalid"]; ok {
a.Invalid = c.Summary
}
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return out[i].Setting < out[j].Setting })
return out, nil
}
// kept are the entries the manager keeps on the bus, by their local names in the manifest (§2 to §9).
func (m *Manager) kept() (map[string]keptState, map[string]keptLog) {
states, logs := map[string]keptState{}, map[string]keptLog{}
if bus, ok := m.Store.(*BusStore); ok {
states["licences"], states["bindings"] = bus.LicencesState, bus.BindingsState
states["leases"], states["offered"] = bus.LeasesState, bus.OfferedState
logs["usage"], logs["audit"] = bus.UsageLog, bus.AuditLog
}
if m.Carry != nil {
states["carried"] = m.Carry.State
}
if m.Conditions != nil {
states["conditions"] = m.Conditions
}
return states, logs
}
// Kept answers one entry the manager keeps, or every one (§41): a state's keys and their count, a log's subjects with
// their last entry and its fill against its cap; with key, one key's value or one subject's last entry.
func (m *Manager) Kept(entry, key string) (map[string]any, error) {
states, logs := m.kept()
out := map[string]any{}
names := []string{}
for n := range states {
names = append(names, n)
}
for n := range logs {
names = append(names, n)
}
sort.Strings(names)
if entry != "" && !slices.Contains(names, entry) {
return nil, fmt.Errorf("the manager keeps no entry %q: it keeps %s", entry, strings.Join(names, ", "))
}
for _, n := range names {
if entry != "" && n != entry {
continue
}
if st, ok := states[n]; ok {
a, err := keptStateAnswer(st, key)
if err != nil {
return nil, fmt.Errorf("%s: %w", n, err)
}
out[n] = a
continue
}
a, err := keptLogAnswer(logs[n], key)
if err != nil {
return nil, fmt.Errorf("%s: %w", n, err)
}
out[n] = a
}
return out, nil
}
func keptStateAnswer(st keptState, key string) (map[string]any, error) {
keys, err := st.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
a := map[string]any{"kind": "state", "keys": keys, "fill": map[string]any{"keys": len(keys), "cap": "none declared"}}
if key != "" {
e, err := st.Get(key)
if err != nil {
return nil, err
}
if e == nil {
a["value"] = nil
} else {
a["value"] = e.Value
}
}
return a, nil
}
func keptLogAnswer(l keptLog, key string) (map[string]any, error) {
info, err := l.Info()
if err != nil {
return nil, err
}
a := map[string]any{"kind": "log", "fill": map[string]any{"entries": info.Entries, "bytes": info.Bytes,
"maxBytes": info.MaxBytes}}
if key != "" {
e, err := l.Last(key)
if err != nil {
return nil, err
}
a["last"] = e
return a, nil
}
newest, err := l.Newest("")
if err != nil {
return nil, err
}
subjects := map[string]any{}
for _, e := range newest {
subjects[e.Key] = e
}
a["subjects"] = subjects
return a, nil
}
// Resync says again what a consumer, the vault or a node may have missed (§42): binding.changed for every binding,
// holdings.asked, each condition held, and licence-put of each licence's access token. Answers what it said.
func (m *Manager) Resync(ctx context.Context) (map[string]any, error) {
bindings, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
said := map[string]any{}
consumers := []string{}
for _, b := range bindings {
m.changed(b, b.Licence)
consumers = append(consumers, b.Consumer)
}
said["binding.changed"] = consumers
if err := m.AskHoldings(CauseOf(ctx).By); err != nil {
return said, fmt.Errorf("holdings.asked could not be said: %w", err)
}
said["holdings.asked"] = true
if err := m.SayConditions(ctx, true); err != nil {
return said, err
}
conditions, err := m.KeptConditions()
if err != nil {
return said, err
}
keys := []string{}
for _, c := range conditions {
keys = append(keys, c.Body.Key)
}
said["conditions"] = keys
licences, err := m.Store.Licences(ctx)
if err != nil {
return said, err
}
put := []string{}
var errs []error
for _, l := range licences {
if l.Generation == 0 {
continue
}
key := KeyFor(l.Name)
// The access token is built from the grant, which the vault hands the manager; it hands a licence's access
// token only to a machine a binding names (§17), and the manager's machine may have none.
access, err := m.Grants.Get(ctx, key, PartGrant)
if err == nil && l.Kind == "subscription" {
var g FullGrant
if err = json.Unmarshal([]byte(access), &g); err == nil {
access = accessOf(g)
}
}
if err == nil {
err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access)
}
access = ""
if err != nil {
errs = append(errs, fmt.Errorf("the access token of %s: %w", l.Name, err))
continue
}
put = append(put, l.Name)
}
said["licence-put"] = put
return said, errors.Join(errs...)
}
// KeptConditions is each key of the state conditions with its body and since (§43), by key.
func (m *Manager) KeptConditions() ([]KeptCondition, error) {
out := []KeptCondition{}
if m.Conditions == nil {
return out, nil
}
keys, err := m.Conditions.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
for _, k := range keys {
e, err := m.Conditions.Get(k)
if err != nil {
return nil, err
}
var c KeptCondition
if e == nil || json.Unmarshal(e.Value, &c) != nil {
continue
}
out = append(out, c)
}
return out, nil
}
// Carried is the carry-over's record, or the failure its last run names while it has not ended; with run and no
// record it carries over again, and is refused once carried (§44).
func (m *Manager) Carried(ctx context.Context, run bool) (map[string]any, error) {
if m.Carry == nil {
return nil, errors.New("this manager has nothing to carry over")
}
if m.Carry.Ended() {
if run {
return nil, errors.New("the carry-over has ended: carried.postgres is written, and it is never done again")
}
e, err := m.Carry.State.Get(CarriedKey)
if err != nil || e == nil {
return nil, err
}
var rec CarriedRecord
if err := json.Unmarshal(e.Value, &rec); err != nil {
return nil, err
}
return map[string]any{"carried": rec}, nil
}
if !run {
return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, nil
}
bus, _ := m.Store.(*BusStore)
rec, err := m.Carry.Run(ctx, m, bus)
if serr := m.SayConditions(ctx, false); serr != nil {
m.Log("saying the conditions after the carry-over: %v", serr)
}
if err != nil {
return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, err
}
return map[string]any{"carried": rec}, nil
}
@@ -0,0 +1,328 @@
package main
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
)
// served is every verb and tool the binary serves, by name, run on mm's manager.
func served(t *testing.T, mm *miniMesh) map[string]stdTool {
t.Helper()
built = mm.m
t.Cleanup(func() { built = nil })
out := map[string]stdTool{}
for _, tool := range tools() {
out[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
}
return out
}
// answer is a tool's answer as JSON, decoded into a generic form.
func answer(t *testing.T, run stdTool, args map[string]any) any {
t.Helper()
got, err := run(args)
if err != nil {
t.Fatalf("%v answered %v", args, err)
}
raw, _ := json.Marshal(got)
var v any
_ = json.Unmarshal(raw, &v)
return v
}
// design 39 bus-and-switch §34/AC1, §47, B40: an answer per licence carries today's fields, its organisation, usage,
// room or why it is not known, whether it is the next target, when and from where it was adopted and its open
// conditions, and no token.
func TestLicencesAnswersEachFieldAndNoToken(t *testing.T) {
mm := fourOnA(t)
ctx := context.Background()
_ = mm.store.SaveLicence(ctx, Licence{Name: "k", Kind: "api-key", Generation: 1, AdoptedAt: t0.UnixMilli(),
AdoptedFrom: "a file"})
_, _ = saying(mm)
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
_ = mm.m.SayConditions(ctx, false)
got := answer(t, served(t, mm)["licences"], nil).([]any)
by := map[string]map[string]any{}
for _, l := range got {
f := l.(map[string]any)
by[f["name"].(string)] = f
for _, k := range []string{"name", "kind", "account", "organisation", "accessExpiresAt", "refreshExpiresAt",
"failures", "bound", "usage", "room", "target", "adoptedAt", "adoptedFrom", "conditions"} {
if _, ok := f[k]; !ok {
t.Errorf("%s has no %s: %v", f["name"], k, f)
}
}
}
if by["b"]["target"] != true || by["c"]["target"] != false || by["a"]["room"] != 5.0 ||
by["k"]["room"] != "is an API key, whose room is never known" || by["k"]["adoptedFrom"] != "a file" ||
len(by["a"]["bound"].([]any)) != 4 || by["a"]["usage"].(map[string]any)["weeklyResetsAt"] != "2026-11-14T09:00:00Z" {
t.Fatalf("licences %v", by)
}
if cs := by["a"]["conditions"].([]any); len(cs) != 1 || cs[0].(map[string]any)["key"] != "licence.a.near-limit" {
t.Fatalf("a's conditions %v", cs)
}
raw, _ := json.Marshal(got)
for _, token := range []string{"rt-", "at-", "local-"} {
if strings.Contains(string(raw), token) {
t.Fatalf("licences holds %s: %s", token, raw)
}
}
}
// design 39 bus-and-switch §35/AC1: since and limit bound the answer, newest first; raw adds the vendor's answer.
func TestUsageIsBoundedBySinceAndLimit(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_ = mm.store.SaveLicence(ctx, sub("a", nil))
for i := range 30 {
_ = mm.store.RecordUsage(ctx, "a", t0.Add(time.Duration(i)*time.Minute).UnixMilli(), UsageReading{},
map[string]any{"i": float64(i)})
}
run := served(t, mm)["usage"]
rows := answer(t, run, map[string]any{"licence": "a"}).([]any)
first := rows[0].(map[string]any)
if len(rows) != 20 || first["at"] != float64(t0.Add(29*time.Minute).UnixMilli()) || first["raw"] != nil {
t.Fatalf("by default %d rows, the first %v", len(rows), first)
}
since := t0.Add(25 * time.Minute).UTC().Format(time.RFC3339)
rows = answer(t, run, map[string]any{"since": since, "limit": 3.0, "raw": true}).([]any)
if len(rows) != 3 || rows[0].(map[string]any)["raw"].(map[string]any)["i"] != 29.0 {
t.Fatalf("since and limit 3: %v", rows)
}
if rows = answer(t, run, map[string]any{"since": since, "limit": 5000.0}).([]any); len(rows) != 5 {
t.Fatalf("since: %d rows", len(rows))
}
if _, err := run(map[string]any{"since": "yesterday"}); err == nil {
t.Fatal("a since that is not a time is answered")
}
}
// design 39 bus-and-switch §37/AC1: a switch by usage and one by a person are answered, newest first, with their
// causes, a switch by usage with the reading that moved them; licence and consumer narrow the answer.
func TestSwitchesAnswersBothCauses(t *testing.T) {
mm := fourOnA(t)
ctx := context.Background()
_, _ = mm.m.ApplyRule(ctx, "", false, false)
mm.now = t0.Add(time.Minute)
_, _ = mm.m.Bind(WithCause(ctx, Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
run := served(t, mm)["licences_switches"]
got := answer(t, run, nil).([]any)
if len(got) != 2 {
t.Fatalf("switches %v", got)
}
person, usage := got[0].(map[string]any), got[1].(map[string]any)
if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil ||
usage["by"] != ByUsage || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" {
t.Fatalf("switches %v", got)
}
if got := answer(t, run, map[string]any{"licence": "c"}).([]any); len(got) != 1 {
t.Fatalf("of c %v", got)
}
if got := answer(t, run, map[string]any{"consumer": "g14"}).([]any); len(got) != 1 || got[0].(map[string]any)["by"] != ByUsage {
t.Fatalf("of g14 %v", got)
}
}
// design 39 bus-and-switch §38/AC1, §24, §32: reading usage now writes the reading and applies the rule, and with dry
// reads, answers the moves and moves nothing.
func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) {
ctx := context.Background()
for _, dry := range []bool{true, false} {
mm := newMesh(t)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
got := answer(t, served(t, mm)["licences_read_usage"], map[string]any{"dry": dry}).([]any)
r := got[0].(map[string]any)
moved := mm.bound("laptop").Licence == "b@example.org"
if len(got) != 1 || r["reading"].(map[string]any)["weeklyPct"] != 99.0 ||
len(r["switches"].([]any)) != 1 || moved == dry || len(mm.usage.under("")) != 1 {
t.Fatalf("dry %v: answered %v, moved %v", dry, got, moved)
}
}
}
// design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an
// acting tool waits for the carry-over (§11).
func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) {
mm := fourOnA(t)
mm.m.Settings.SwitchAuto = false
run := served(t, mm)["licences_switch_now"]
if got := answer(t, run, map[string]any{"dry": true}).([]any); len(got) != 1 || mm.bound("ace").Licence != "a" {
t.Fatalf("dry %v", got)
}
if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" {
t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence)
}
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })}
if _, err := run(nil); err == nil || err.Error() != NotCarried {
t.Fatalf("before the carry-over answered %v", err)
}
}
// design 39 bus-and-switch §40/AC1, B18: a value set at node level is answered with its level, the rest with the
// default, each with its value and default, and an invalid one saying why.
func TestSettingsAnswersTheLevel(t *testing.T) {
mm := newMesh(t)
mm.m.Settings.SwitchAt, mm.m.Settings.WarnAt = 80, 85
mm.m.ConfigLevels = func(context.Context) (map[string]string, error) {
return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil
}
got := answer(t, served(t, mm)["licences_settings"], nil).([]any)
by := map[string]map[string]any{}
for _, s := range got {
f := s.(map[string]any)
by[f["setting"].(string)] = f
}
if len(by) != len(Defaults.Values()) || by["switch_at_pct"]["level"] != "node" || by["switch_at_pct"]["value"] != 80.0 ||
by["switch_at_pct"]["default"] != 99.0 || by["usage_minutes"]["level"] != "default" ||
!strings.Contains(fmtAny(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") ||
!strings.Contains(fmtAny(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil {
t.Fatalf("settings %v", by)
}
mm.m.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") }
if _, err := served(t, mm)["licences_settings"](nil); err == nil {
t.Fatal("mesh-config not answering is answered as the default")
}
}
func fmtAny(v any) string { s, _ := v.(string); return s }
// design 39 bus-and-switch §41/AC1, §20: every bus-state entry the manifest asks for is read by licences_kept, with its
// keys or subjects, one key's value and its fill; and no entry holds a token.
func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Requires []json.RawMessage `json:"requires"`
}
_ = json.Unmarshal(raw, &m)
mm := newMesh(t)
ctx := context.Background()
_, _ = saying(mm)
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })}
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)})
_ = mm.m.ReadUsage(ctx)
run := served(t, mm)["licences_kept"]
all := answer(t, run, nil).(map[string]any)
entries := 0
for _, r := range m.Requires {
var e struct {
Name string `json:"name"`
Template string `json:"template"`
}
if json.Unmarshal(r, &e) != nil || e.Template == "" {
continue
}
entries++
got, ok := all[e.Name].(map[string]any)
if !ok || got["fill"] == nil || got["kind"] != map[string]string{"state": "state", "log": "log"}[e.Template] {
t.Errorf("licences_kept does not read %s as a %s: %v", e.Name, e.Template, all[e.Name])
}
}
if entries != 8 || len(all) != entries {
t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", entries, len(all))
}
one := answer(t, run, map[string]any{"entry": "licences", "key": KeyFor(licence1)}).(map[string]any)
if one["licences"].(map[string]any)["value"] == nil {
t.Fatalf("one key %v", one)
}
if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects[KeyFor(licence1)] == nil {
t.Fatalf("usage subjects %v", subjects)
}
if _, err := run(map[string]any{"entry": "tables"}); err == nil {
t.Fatal("an entry not kept is answered")
}
everything, _ := json.Marshal(all)
for _, token := range []string{"rt-secret", "rt-1", "at-1", "local-"} {
if strings.Contains(string(everything), token) {
t.Fatalf("a kept entry holds %s", token)
}
}
}
// design 39 bus-and-switch §42/AC1: resync says binding.changed once per binding, holdings.asked once, each condition
// once, and licence-put of each licence's access once.
func TestResyncSaysEachOnce(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, said := saying(mm)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.login("server", "rt-b", true, t0)})
mm.m.Settings.WarnAt = 100
_ = mm.m.SayConditions(ctx, false)
mm.events, *said = nil, nil
mm.grants.refuseAccess = true // §42: the access token is built from the grant, never read back from the vault
got := answer(t, served(t, mm)["licences_resync"], nil).(map[string]any)
count := map[string]int{}
for _, e := range mm.events {
name, _, _ := strings.Cut(e, " ")
count[name]++
}
puts, _ := got["licence-put"].([]any)
if count["binding.changed"] != 2 || count[HoldingsAsked] != 1 || len(*said) != 1 || len(puts) != 1 || puts[0] != licence1 {
t.Fatalf("said %v, conditions %d, answered %v", mm.events, len(*said), got)
}
}
// design 39 bus-and-switch §43/AC1: each key of conditions is answered with its body and since.
func TestConditionsAnswersEachKey(t *testing.T) {
mm := fourOnA(t)
ctx := context.Background()
_, _ = saying(mm)
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
mm.m.Settings.RoomBelow = 100
_ = mm.m.SayConditions(ctx, false)
got := answer(t, served(t, mm)["licences_conditions"], nil).([]any)
var keys []string
for _, c := range got {
f := c.(map[string]any)
if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" {
t.Fatalf("condition %v", f)
}
keys = append(keys, f["body"].(map[string]any)["key"].(string))
}
if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" {
t.Fatalf("keys %v", keys)
}
}
// design 39 bus-and-switch §44/AC1: licences_carried answers the failure while not carried, carries over with run, then
// answers the record and refuses run.
func TestCarriedRunIsRefusedOnceCarried(t *testing.T) {
mm := newMesh(t)
_, said := saying(mm)
down := true
mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }),
Source: func(context.Context) (CarrySource, error) {
if down {
return nil, errors.New("connection refused")
}
return &memSource{}, nil
}}
run := served(t, mm)["licences_carried"]
if _, err := run(map[string]any{"run": true}); err == nil || len(of(*said, CarryOverFailed)) != 1 {
t.Fatalf("a failed run answered %v, said %v", err, *said)
}
if got := answer(t, run, nil).(map[string]any); !strings.Contains(fmtAny(got["failure"]), "connection refused") {
t.Fatalf("not carried %v", got)
}
down = false
if got := answer(t, run, map[string]any{"run": true}).(map[string]any); got["carried"] == nil ||
strings.Join(of(*said, CarryOverFailed), ",") != "raised,cleared" {
t.Fatalf("carried %v, said %v", got, *said)
}
if got := answer(t, run, nil).(map[string]any); got["carried"].(map[string]any)["tables"] == nil {
t.Fatalf("the record %v", got)
}
if _, err := run(map[string]any{"run": true}); err == nil {
t.Fatal("run is answered once carried")
}
}
@@ -0,0 +1,79 @@
package main
// The vault holds every licence's grant and access token (design 39 bus-and-switch §15, §16, B4, B5): the manager hands
// what it gets to `licence-put` in two steps, with a key the vault made for the call, and reads a grant with `get`,
// sealed to its own public key, for one act.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
"golang.org/x/crypto/nacl/box"
)
// The parts of a licence the vault holds (§15).
const (
PartGrant = "grant"
PartAccess = "access"
)
// Grants is where a licence's two secrets are kept.
type Grants interface {
// Put hands one part of a licence, by its key, to the vault at a generation; an older one is refused.
Put(ctx context.Context, key, part string, generation int64, value string) error
// Get reads one part of a licence from the vault.
Get(ctx context.Context, key, part string) (string, error)
}
// SecretName is the name of one part of a licence in the vault.
func SecretName(key, part string) string { return "licence." + key + "." + part }
// VaultGrants is the vault on the bus, asked as the seat mesh-vault.
type VaultGrants struct{ Keys KeyPair }
// Put asks licence-put's two steps.
func (v VaultGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
raw, err := stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"licence": key, "part": part, "generation": generation})
if err != nil {
return err
}
var opened struct {
Call string `json:"call"`
SealTo string `json:"seal_to"`
}
if err := json.Unmarshal(raw, &opened); err != nil {
return err
}
pub, err := base64.StdEncoding.DecodeString(opened.SealTo)
if err != nil || len(pub) != 32 || opened.Call == "" {
return fmt.Errorf("licence-put answered no call and key to seal to: %s", raw)
}
var to [32]byte
copy(to[:], pub)
sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader)
if err != nil {
return err
}
_, err = stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"call": opened.Call,
"sealed": base64.StdEncoding.EncodeToString(sealed)})
return err
}
// Get asks get, sealed to the manager's public key, and opens the answer.
func (v VaultGrants) Get(_ context.Context, key, part string) (string, error) {
raw, err := stdio.Ask("seat:mesh-vault.get", map[string]any{"name": SecretName(key, part), "public_key": v.Keys.PublicKey})
if err != nil {
return "", err
}
var a struct {
Sealed SealedBox `json:"sealed"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", err
}
return Open(a.Sealed, v.Keys.PrivateKey)
}
@@ -0,0 +1,43 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
)
// design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first
// and then the access, at the next generation; the licence keeps no token once the call returns.
func TestARotationPutsTwiceAndKeepsNoToken(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
if _, err := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); err != nil {
t.Fatal(err)
}
key := KeyFor(licence1)
mm.grants.puts = nil
if r, err := mm.m.Rotate(ctx, licence1, true); err != nil || r["refreshed"] != true {
t.Fatalf("%v %v", r, err)
}
want := []string{SecretName(key, PartGrant) + "@2", SecretName(key, PartAccess) + "@2"}
if strings.Join(mm.grants.puts, " ") != strings.Join(want, " ") {
t.Fatalf("the rotation put %v, want %v", mm.grants.puts, want)
}
if g, _ := mm.grants.Get(ctx, key, PartGrant); !strings.Contains(g, `"refreshToken":"rt-2"`) {
t.Fatalf("the vault holds the grant %s", g)
}
if a, _ := mm.grants.Get(ctx, key, PartAccess); !strings.Contains(a, `"accessToken":"at-2"`) || strings.Contains(a, "refreshToken\":\"rt") {
t.Fatalf("the vault holds the access %s", a)
}
l, _ := mm.store.Licence(ctx, licence1)
raw, _ := json.Marshal(l)
for _, token := range []string{"rt-", "at-"} {
if strings.Contains(string(raw), token) {
t.Fatalf("the licence keeps %s: %s", token, raw)
}
}
if l.Generation != 2 {
t.Fatalf("the licence's generation is %d, want 2", l.Generation)
}
}
@@ -140,6 +140,8 @@ func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
return NextGrant(g, r, time.Now().UnixMilli())
}
// Usage is the vendor's usage answer whole; a failed answer is an error naming its status and reason, the body, never
// only the status (design 39 bus-and-switch §24).
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
if err != nil {
@@ -148,25 +150,38 @@ func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]a
req.Header.Set("authorization", "Bearer "+accessToken)
resp, err := v.client.Do(req)
if err != nil {
return nil, err
return nil, fmt.Errorf("the usage endpoint did not answer: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode)
reason := string(body)
if len(reason) > 400 {
reason = reason[:400]
}
return nil, fmt.Errorf("the usage endpoint answered %d: %s", resp.StatusCode, reason)
}
var out map[string]any
return out, json.NewDecoder(resp.Body).Decode(&out)
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("the usage endpoint answered %d with what is not JSON", resp.StatusCode)
}
return out, nil
}
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows.
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows (design 39 bus-and-switch
// §24), with, as the licence keeps it (§2), when it was last read successfully and why the latest reading failed.
type UsageReading struct {
SessionPct *float64 `json:"sessionPct"`
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
WeeklyPct *float64 `json:"weeklyPct"`
WeeklyResetsAt string `json:"weeklyResetsAt,omitempty"`
SonnetPct *float64 `json:"sonnetPct"`
SonnetResetsAt string `json:"sonnetResetsAt,omitempty"`
ReadAt string `json:"readAt,omitempty"`
ReadError string `json:"readError,omitempty"`
}
// FlattenUsage reads the windows the predecessor read.
// FlattenUsage reads the session, weekly and sonnet windows, each with its reset time (§24).
func FlattenUsage(u map[string]any) UsageReading {
window := func(k string) (*float64, string) {
w, ok := u[k].(map[string]any)
@@ -180,8 +195,9 @@ func FlattenUsage(u map[string]any) UsageReading {
}
return &pct, resets
}
s, resets := window("five_hour")
w, _ := window("seven_day")
so, _ := window("seven_day_sonnet")
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so}
s, sResets := window("five_hour")
w, wResets := window("seven_day")
so, soResets := window("seven_day_sonnet")
return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so,
SonnetResetsAt: soResets}
}
@@ -3,5 +3,11 @@
"floor_minutes": 60,
"failures_to_notify": 3,
"cooldown_hours": 24,
"refresh_warn_days": 3
"refresh_warn_days": 3,
"switch_auto": true,
"switch_at_pct": 99,
"room_below_pct": 90,
"warn_at_pct": 90,
"usage_minutes": 5,
"usage_stale_minutes": 15
}
+10 -2
View File
@@ -3,20 +3,28 @@ module claude-licence-manager
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/jackc/pgx/v5 v5.11.0
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect
)
+29 -17
View File
@@ -1,38 +1,50 @@
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+151 -28
View File
@@ -4,7 +4,74 @@
"slug": "licmgr",
"requires": [
"postgres-database",
"secret"
"secret",
{
"name": "licences",
"storage": "key-value",
"template": "state",
"options": {
"history": 1
}
},
{
"name": "bindings",
"storage": "key-value",
"template": "state",
"options": {
"history": 8
}
},
{
"name": "leases",
"storage": "key-value",
"template": "state",
"options": {
"history": 1,
"key-ttl": true
}
},
{
"name": "offered",
"storage": "key-value",
"template": "state",
"options": {
"history": 1,
"key-ttl": true
}
},
{
"name": "usage",
"storage": "stream",
"template": "log",
"options": {
"max-age-days": 35,
"max-mib": 256
}
},
{
"name": "audit",
"storage": "stream",
"template": "log",
"options": {
"max-mib": 64
}
},
{
"name": "carried",
"storage": "key-value",
"template": "state",
"options": {
"history": 1
}
},
{
"name": "conditions",
"storage": "key-value",
"template": "state",
"options": {
"history": 1
}
}
],
"contributes": {
"postgres-database": {
@@ -24,6 +91,15 @@
"grant-key": "${dir:state}/grant.key"
}
},
"uses": [
"operator-channel"
],
"invokes": [
"seat:operator-channel.tell",
"seat:mesh-vault.licence-put",
"seat:mesh-vault.get",
"seat:mesh-config.config"
],
"seats": [
{
"name": "anthropic-licence-manager",
@@ -63,18 +139,14 @@
"emits": [
"licence.adopted",
"licence.refused",
"licence.bound",
"licence.switched",
"licence.released",
"licence.rotated",
"licence.failing",
"usage.read"
"usage.read",
"binding.changed",
"holdings.asked"
],
"state": [
"bindings"
],
"reads": [
"claude-code.holdings"
"consumes": [
"claude-code.holdings-changed"
],
"config": {
"cadence_minutes": {
@@ -111,6 +183,52 @@
"minimum": 0,
"trusted": true,
"default": 3
},
"switch_auto": {
"type": "boolean",
"description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
"trusted": true,
"default": true
},
"switch_at_pct": {
"type": "number",
"description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
"minimum": 1,
"maximum": 100,
"trusted": true,
"default": 99
},
"room_below_pct": {
"type": "number",
"description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
"minimum": 1,
"maximum": 100,
"trusted": true,
"default": 90
},
"warn_at_pct": {
"type": "number",
"description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.",
"minimum": 1,
"maximum": 100,
"trusted": true,
"default": 90
},
"usage_minutes": {
"type": "number",
"description": "How often each subscription's usage is read, in minutes.",
"minimum": 1,
"maximum": 60,
"trusted": true,
"default": 5
},
"usage_stale_minutes": {
"type": "number",
"description": "How old a subscription's last successful usage reading may grow before its room is not known and it is said, in minutes.",
"minimum": 5,
"maximum": 180,
"trusted": true,
"default": 15
}
},
"resources": [
@@ -133,28 +251,33 @@
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
"takes-config": [
"cadence_minutes",
"floor_minutes",
"failures_to_notify",
"cooldown_hours",
"refresh_warn_days",
"switch_auto",
"switch_at_pct",
"room_below_pct",
"warn_at_pct",
"usage_minutes",
"usage_stale_minutes"
],
"merge": "json",
"template": "files/settings.json"
},
{
"id": "prepare",
"type": "process",
"name": "claude-licence-manager-prepare",
"artifact": "code",
"run": [
"./claude-licence-manager",
"prepare"
],
"run-once": true,
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
},
"restart-on": [
"database-url"
]
}
],
"tools": [
"licences_switches",
"licences_read_usage",
"licences_switch_now",
"licences_settings",
"licences_kept",
"licences_resync",
"licences_conditions",
"licences_carried"
],
"build": {
"artifacts": [
{
@@ -29,10 +29,6 @@ func TestTheReadingsLayoutIsAPublishedPackageOfThisModule(t *testing.T) {
if !slices.Contains(m.Reads, engineDataRead) {
t.Errorf("reads %v do not name %s", m.Reads, engineDataRead)
}
for _, a := range m.Build.Artifacts {
if a.Name == "diskreadings" && a.Kind == "package" && a.Series == "0.1" && len(a.Paths) == 1 && a.Paths[0] == "diskreadings" {
return
}
}
t.Errorf("no package artifact diskreadings of series 0.1 in %s", raw)
// Issue 1966's bootstrap: the package artifact diskreadings is out of the manifest until the new builder runs; the follow-up
// puts this check back.
}
-9
View File
@@ -24,15 +24,6 @@
"loads": [
"disk-load"
]
},
{
"name": "diskreadings",
"kind": "package",
"language": "go",
"paths": [
"diskreadings"
],
"series": "0.1"
}
]
},
@@ -57,7 +57,7 @@ func TestTheVerbsSpeakTheSeatsContract(t *testing.T) {
t.Errorf("%s's body %s is not the contract's: %v", event, b, err)
}
}
// The caller list is read on each call: one that cannot be read answers no verb, and registry_callers says it as read.
// The caller list is read held: one never read answers no verb, and registry_callers says it as read.
unread := func() (holder.Callers, error) { return nil, errors.New("config refused") }
for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, unread) {
if tool.Name == holder.ImageSeat+"."+holder.VerbVersions {
@@ -248,7 +248,7 @@ func arg[T any](args map[string]any) (T, error) {
func str(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
// tools are distribution's tools over v; each call that checks a caller reads the caller list on that call (callers:
// tools are distribution's tools over v; each call that checks a caller reads the caller list held (callers:
// the config value as mesh-config resolves it now), so an operator's change of it holds from the next call on.
func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.Callers, error)) []stdio.Tool {
now := func() (*holder.Verbs, error) {
@@ -417,10 +417,10 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
}
return map[string]any{"callers": c}, nil
}},
stdio.Tool{Name: "registry_health", Description: "distribution's health check, run by the runtime each minute: versions of each seat asked through the store's read side (the program's port 5000); fails naming the program when it does not answer.",
stdio.Tool{Name: "registry_health", Description: "distribution's health check, run by the runtime each minute: versions of each seat asked through the store's read side (the program's port 5100); fails naming the program when it does not answer.",
Input: obj(map[string]any{}),
Run: func(map[string]any) (any, error) {
front, _ := url.Parse("http://127.0.0.1:5000")
front, _ := url.Parse("http://127.0.0.1:5100")
return holder.Health(&holder.OCI{Base: front}, finding)
}},
stdio.Tool{Name: "registry_incoming", Description: "Open transfers of incoming and its fill; with drop, drops one by hand (a hand act, why required).",
@@ -71,7 +71,7 @@ func run(ctx context.Context, getenv func(string) string) error {
d, err := oci.Digest(name, tag)
return d != "", err
}}
srv := &http.Server{Addr: or(getenv("MESH_REGISTRY_LISTEN"), ":5000"), Handler: front, ReadHeaderTimeout: 30 * time.Second}
srv := &http.Server{Addr: or(getenv("MESH_REGISTRY_LISTEN"), ":5100"), Handler: front, ReadHeaderTimeout: 30 * time.Second}
errs := make(chan error, 1)
go func() { errs <- srv.ListenAndServe() }()
logger.Printf("serving the store's port %s in front of %s", srv.Addr, store)
@@ -93,11 +93,15 @@ func ParseCallers(raw string) (Callers, error) {
return selfNotReleasing(c)
}
// ConfiguredCallers is the config value `callers` as mesh-config resolved it for this machine, read through the tools
// runtime's stdio.Config on each call (never an environment variable: nothing renders a config value into a
// program's environment); unset is DefaultCallers, and Self on release's list is refused as ParseCallers refuses it.
// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of
// the value, so a call never asks (issue 1966: an ask that timed out refused every verb).
var configured = stdio.HoldVerbCallers(CallersConfig, VerbsOf(ImageSeat), DefaultCallers())
// ConfiguredCallers is the config value `callers` as mesh-config resolved it for this machine, held from the tools
// runtime's watch of it (never an environment variable: nothing renders a config value into a program's environment);
// unset is DefaultCallers, and Self on release's list is refused as ParseCallers refuses it.
func ConfiguredCallers() (Callers, error) {
c, err := stdio.ConfiguredVerbCallers(CallersConfig, VerbsOf(ImageSeat), DefaultCallers())
c, err := configured()
if err != nil {
return nil, err
}
@@ -17,7 +17,7 @@ func Health(front Catalog, finding func(kind, subject, words string, raised bool
for _, seat := range Seats {
got, err := front.Versions(ProbeName)
if err != nil {
words := fmt.Sprintf("the store's read side does not answer %s's versions through distribution's program (port 5000): %v; is the program distribution running?", seat, err)
words := fmt.Sprintf("the store's read side does not answer %s's versions through distribution's program (port 5100): %v; is the program distribution running?", seat, err)
if finding != nil {
finding(FindingReadSide, seat, words, true)
}
@@ -242,8 +242,13 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
t.Errorf("resumed publish = %+v", a)
}
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err == nil {
t.Error("a chunk sent twice was taken: the transfer goes on from the chunks held")
// A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again
// (mesh-delivery's parts.Receiver.Take, 1966/AC203-1, AC203-2): its earlier chunks are dropped and it is taken.
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 2, Data: blob[:1000]}); err == nil {
t.Error("a chunk out of sequence was taken: the transfer goes on from the chunks held")
}
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil {
t.Errorf("a chunk 0 again was refused: %v", err)
}
a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]})
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
@@ -835,18 +840,24 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
Resources []map[string]any `json:"resources"`
Consumes []string `json:"consumes"`
Packages map[string]string `json:"packages"`
Listens []struct {
Name string `json:"name"`
Port int `json:"port"`
} `json:"listens"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
// The builder's question is its published package, taken by its series (ADR 0634 §1).
if m.Packages["builds"] != "0.1" {
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
}
// E15: the provision artifact-store retired (ADR 0632 §3): distribution provides and serves nothing, and the
// store is found by its seats' holder and the listen it opens to the mesh.
if len(m.Provides) != 0 || len(m.Serves) != 0 {
t.Errorf("provides = %+v, serves = %v, want neither: the provision artifact-store retired", m.Provides, m.Serves)
// E15: the provision artifact-store retires (ADR 0632 §3); the bootstrap keeps it as an alias on port 5100 while
// the running node-engines still find the store by it (issue 1966), and nothing else is provided or served.
alias, _ := m.Serves["artifact-store"].(map[string]any)
port, _ := alias["port"].(float64)
if len(m.Provides) != 1 || m.Provides[0].Name != "artifact-store" || len(m.Serves) != 1 || port != 5100 {
t.Errorf("provides = %+v, serves = %v, want only the artifact-store alias on port 5100", m.Provides, m.Serves)
}
claims := map[string][]string{}
for _, c := range m.Claims {
@@ -867,7 +878,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
switch r["id"] {
case "store":
store = i
if ports, _ := r["ports"].([]any); len(ports) != 1 || ports[0] != "127.0.0.1:5001:5000" {
if ports, _ := r["ports"].([]any); len(ports) != 1 || ports[0] != "127.0.0.1:5001:5001" {
t.Errorf("the store's ports = %v, want the loopback only", r["ports"])
}
// The check of another kind beside the program's tool (ADR 0227 rule 8, ADR 0240 rule 2; the operator,
@@ -885,6 +896,22 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
if store < 0 || program < 0 || store > program {
t.Errorf("store at %d, program at %d: want the store listed first", store, program)
}
// The store's address is the listen registry where the machine put it (ADR 0632 §1), and the program binds it
// itself: no container's mapping may name its port on either side, or the composer reads that mapping's loopback
// side as the address (the move of 2026-11-08 23:25 UTC cut the store off so; issue 1966 note 109).
if len(m.Listens) != 1 || m.Listens[0].Port != 5100 {
t.Fatalf("listens = %+v, want registry on 5100, where every machine fetches", m.Listens)
}
for _, r := range m.Resources {
ports, _ := r["ports"].([]any)
for _, p := range ports {
for _, side := range strings.Split(fmt.Sprint(p), ":") {
if side == "5100" {
t.Errorf("%v maps %v: want 5100 bound by the program, named by no container", r["id"], p)
}
}
}
}
// Running review F2: incoming asks the bus 8 GiB, the most the objects template allows, equal to IncomingCap
// (said only where no binding is read); transfers keeps for good, the holder dropping an idle one by its key
// in idle, a state of keys with their own time to live.
+19 -4
View File
@@ -2,7 +2,21 @@
"module": "distribution",
"version": "2",
"packages": {
"builds": "0.1"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
},
"provides": [
{
"name": "artifact-store",
"scope": "mesh",
"credential": {
"form": "none"
}
}
],
"serves": {
"artifact-store": {
"port": 5100
}
},
"claims": [
{
@@ -133,7 +147,7 @@
"listens": [
{
"name": "registry",
"port": 5000,
"port": 5100,
"protocol": "tcp",
"from": "mesh",
"why": "every machine pulls images and artifacts from here; distribution's program serves it in front of the store"
@@ -181,12 +195,13 @@
"name": "mesh-registry",
"image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
"ports": [
"127.0.0.1:5001:5000"
"127.0.0.1:5001:5001"
],
"volumes": [
"/var/lib/mesh-registry:/var/lib/registry"
],
"env": {
"REGISTRY_HTTP_ADDR": ":5001",
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
},
"health": {
@@ -211,7 +226,7 @@
"env": {
"MESH_BROKER_FILE": "/var/lib/mesh/registry/broker",
"MESH_REGISTRY_LOOPBACK": "http://127.0.0.1:5001",
"MESH_REGISTRY_LISTEN": ":5000"
"MESH_REGISTRY_LISTEN": ":5100"
},
"user": "distribution",
"health": {
@@ -122,3 +122,83 @@ func TestAddressWaitIsAtMostTwoSeconds(t *testing.T) {
t.Fatal("mesh.conf has no ExecStartPre")
}
}
// The two public upstreams are the module's settings, defaulting to the two the resolver always used, placed
// as server lines beside no-resolv: a person sets them per node or mesh-wide without a build.
func TestUpstreamsAreConfigValues(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Config map[string]struct {
Default string `json:"default"`
Pattern string `json:"pattern"`
} `json:"config"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
conf, err := os.ReadFile(filepath.Join(moduleDir, "files", "dnsmasq.conf"))
if err != nil {
t.Fatal(err)
}
for key, def := range map[string]string{"upstream-resolver-first": "1.1.1.1", "upstream-resolver-second": "8.8.8.8"} {
c, ok := m.Config[key]
if !ok || c.Default != def {
t.Errorf("config %s = %+v, want default %s", key, c, def)
}
if !regexp.MustCompile(c.Pattern).MatchString(def) {
t.Errorf("pattern %q refuses the default %s", c.Pattern, def)
}
if !strings.Contains(string(conf), "\nserver=${config:"+key+"}\n") {
t.Errorf("dnsmasq.conf does not place %s as a server line", key)
}
}
if strings.Contains(string(conf), "server=1.1.1.1") || strings.Contains(string(conf), "server=8.8.8.8") {
t.Error("dnsmasq.conf still hard-codes an upstream")
}
if !strings.Contains(string(conf), "\nno-resolv\n") {
t.Error("dnsmasq.conf lost no-resolv")
}
}
// The resolver's configuration keeps what made every earlier resolver of the mesh go wrong (moved from mesh-sdk's
// catalogue tests, which could not follow the file when its upstreams became settings): it answers on the private
// address and loopback only, by address and never by interface (novox/hq issue 110), binds once and never
// dynamically (issue 348), reads no hosts file and no operator's files (ADR 0199), and takes none of the addresses
// systemd-resolved holds. no-resolv and the two server lines are held by TestUpstreamsAreConfigValues.
func TestTheResolverConfigurationKeepsItsGuards(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "dnsmasq.conf"))
if err != nil {
t.Fatal(err)
}
config := "\n" + string(raw)
for _, want := range []string{
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n",
"\nno-hosts\n", "\nconf-file=/etc/mesh-resolver/zones.conf\n", "\nconf-file=/etc/mesh-resolver/nodes.conf\n",
"\ndomain-needed\n", "\nbogus-priv\n", "\nbind-interfaces\n",
} {
if !strings.Contains(config, want) {
t.Errorf("the resolver's configuration lacks %q", strings.TrimSpace(want))
}
}
for _, line := range strings.Split(config, "\n") {
for _, never := range []string{"bind-dynamic", "interface=", "conf-dir=", "addn-hosts=", "listen-address=${setting:",
"listen-address=127.0.0.53", "listen-address=127.0.0.54", "listen-address=127.0.0.55"} {
if strings.HasPrefix(line, never) {
t.Errorf("the resolver's configuration says %q", line)
}
}
}
// The zone and node files the configuration reads are the ones the manifest's facts place.
manifest, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
for _, p := range []string{"/etc/mesh-resolver/zones.conf", "/etc/mesh-resolver/nodes.conf"} {
if !strings.Contains(string(manifest), `"path": "`+p+`"`) {
t.Errorf("the manifest places no file at %s, which the configuration reads", p)
}
}
}
+3 -3
View File
@@ -57,12 +57,12 @@ listen-address=127.0.0.1
# upstreams would find itself, and every query it could not answer locally would
# loop until its receive queue filled. That is not theoretical: it filled with
# 15KB of queries and every lookup on the machine hung. no-resolv makes that loop
# impossible: the upstreams are the two lines below, and nothing on the machine
# impossible: the upstreams are the two lines below (the module's settings upstream-resolver-first and -second), and nothing on the machine
# can redirect them. The mesh's own names never reach them: the local= line in
# the file above stops them here, answered or refused.
no-resolv
server=1.1.1.1
server=8.8.8.8
server=${config:upstream-resolver-first}
server=${config:upstream-resolver-second}
# Both upstreams validate DNSSEC and say so with the Authenticated Data bit,
# and this passes that bit down rather than dropping it, which dnsmasq does
+16
View File
@@ -38,6 +38,22 @@
"dnsmasq_names",
"dnsmasq_resolve"
],
"config": {
"upstream-resolver-first": {
"type": "string",
"description": "The first public resolver this resolver forwards to for every name that is not the mesh's own, as an IP address. It is placed as a server line beside no-resolv, so it must never be an address that resolves through this machine; both upstreams should validate DNSSEC, since the authenticated-data bit is passed down (ADR 0194, ADR 0223). Set per node or for the whole mesh.",
"trusted": false,
"pattern": "^[0-9A-Fa-f.:]+$",
"default": "1.1.1.1"
},
"upstream-resolver-second": {
"type": "string",
"description": "The second public resolver this resolver forwards to, as an IP address, tried beside the first so one upstream being down does not stop name resolution. The same rules as upstream-resolver-first.",
"trusted": false,
"pattern": "^[0-9A-Fa-f.:]+$",
"default": "8.8.8.8"
}
},
"listens": [
{
"name": "dns-udp",
@@ -451,7 +451,7 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) {
t.Fatal(err)
}
// The builder's question is its published package, taken by its series (ADR 0634 §1).
if m.Packages["builds"] != "0.1" {
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
}
// Both package seats are claimed serving the four verbs this bundle answers as <seat>.<verb>, over its own incoming
@@ -6,7 +6,7 @@ package main
// the built package-registry bundle, played over its real stdin and stdout as the node's tool runtime plays it, its
// `incoming` an object store and its `transfers` and `findings` key-value buckets of a nats-server of this test alone
// (so the publish in parts runs through mesh-sdk's parts.BusObjects and BusTransfers, ruling 4 of the lead's 14:0x), the
// builder's builder_versions answered on mesh/ask, the config value `callers` on mesh/provision.get, and the forge an
// builder's builder_versions answered on mesh/ask, the config value `callers` watched on mesh/provision.watch, and the forge an
// httptest server that takes reads only with the tools' token and writes only with the packages account's.
//
// A Go package the builder numbered goes up in two chunks, is uploaded whole as the packages account and says
@@ -123,8 +123,12 @@ func (p *player) handle(m wire) {
_ = json.Unmarshal(a.Body, &q)
d, ok := p.numbered[q.Name+"@"+q.Version]
p.answer(m.ID, map[string]any{"recorded": ok && d == q.Digest, "why": "not numbered with that digest"}, nil)
case m.Method == "mesh/provision.get" && a.Kind == "config":
p.answer(m.ID, nil, nil) // `callers` unset: design 51's list
case m.Method == "mesh/provision.watch" && a.Kind == "config":
// The watch of `callers`, as the runtime answers it: the value now as one change (mesh/provision,
// named by the watch's id) handed over before the watch is answered, here unset, so design 51's list holds.
p.send(map[string]any{"id": "change-" + watchID(m.ID), "method": "mesh/provision", "params": map[string]any{
"watch": watchID(m.ID), "change": map[string]any{"kind": "config", "key": a.Key}}})
p.answer(m.ID, map[string]any{}, nil)
case m.Method == "mesh/seat.publish":
p.answer(m.ID, map[string]any{}, p.nc.Publish(a.Subject, a.Body))
case m.Method == "mesh/publish":
@@ -138,6 +142,15 @@ func (p *player) handle(m wire) {
}
}
// watchID is the watch's id as a string, the form the change names it by.
func watchID(raw json.RawMessage) string {
var id string
if json.Unmarshal(raw, &id) != nil {
return string(raw)
}
return id
}
func (p *player) objects(m wire, local, name string, data []byte) {
os, err := p.js.ObjectStore(p.ctx, "gitea_"+local)
if err != nil {
+5
View File
@@ -5,3 +5,8 @@ GITEA__database__NAME=${bound:postgres-database:as}
GITEA__database__USER=${bound:postgres-database:as}
GITEA__database__PASSWD=${secret:postgres-database}
GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1
# Issue 1966: cap the forge's own pool; the store's 100 connections are shared by every module on this machine.
# SSH git operations go through the forge's private API, so this pool carries them.
GITEA__database__MAX_OPEN_CONNS=${config:database-max-open-connections}
GITEA__database__MAX_IDLE_CONNS=${config:database-max-idle-connections}
GITEA__database__CONN_MAX_LIFETIME=${config:database-connection-lifetime}
@@ -77,10 +77,12 @@ func DefaultCallers() Callers {
VerbVersions: {stdio.AnyCaller}}
}
// ConfiguredCallers is the config value `callers` as mesh-config resolved it; unset is DefaultCallers.
func ConfiguredCallers() (Callers, error) {
return stdio.ConfiguredVerbCallers("callers", Verbs, DefaultCallers())
}
// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of
// the value, so a call never asks (issue 1966).
var configured = stdio.HoldVerbCallers("callers", Verbs, DefaultCallers())
// ConfiguredCallers is the config value `callers` as mesh-config resolved it, held from its watch; unset is DefaultCallers.
func ConfiguredCallers() (Callers, error) { return configured() }
// Publish is a publish as the seats' contract names it (mesh-contracts npmpackageregistry and gopackageregistry share
// one shape): what the tools bundle reads from either seat's PublishInput.
+24 -1
View File
@@ -2,7 +2,7 @@
"module": "gitea",
"version": "1",
"packages": {
"builds": "0.1"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
},
"moved": true,
"requires": [
@@ -187,6 +187,26 @@
]
},
"config": {
"database-max-open-connections": {
"type": "number",
"description": "The most connections the forge's own database pool holds open to the store. The store's 100 connections are shared by every module on its machine, and the forge's git operations over ssh go through the forge's private API, which this pool carries: 40 leaves room for the others.",
"trusted": false,
"minimum": 1,
"default": 40
},
"database-max-idle-connections": {
"type": "number",
"description": "The connections the forge's database pool keeps idle, ready for the next request. They count against the store's 100 connections, shared on its machine, so keep this well under the open-connection cap.",
"trusted": false,
"minimum": 0,
"default": 10
},
"database-connection-lifetime": {
"type": "string",
"description": "How long the forge's database pool keeps one connection before it closes and reopens it, as a duration such as 5m. A shorter lifetime gives back connections of the store's 100 connections, shared on its machine, sooner.",
"trusted": false,
"default": "5m"
},
"url": {
"type": "string",
"description": "The address the module's tools reach the forge at, as a URL; unset, the code falls back to its environment, else this machine's gitea on its port.",
@@ -286,6 +306,9 @@
{
"id": "server",
"type": "container",
"restart-on": [
"server-env"
],
"health": {
"kind": "http",
"endpoint": "web",
+26
View File
@@ -0,0 +1,26 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
// The forge's own database pool is capped (issue 1966): the store's connections are shared by every module on the machine.
// The caps are config values placed into the env-file, and a changed env-file recreates the container.
const env = readFileSync(new URL("../files/server.env", import.meta.url), "utf8");
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
test("the forge's pool caps are config values, placed in server.env, and a change recreates the container", () => {
const lines = env.split("\n");
const placed: Record<string, [string, unknown]> = {
GITEA__database__MAX_OPEN_CONNS: ["database-max-open-connections", 40],
GITEA__database__MAX_IDLE_CONNS: ["database-max-idle-connections", 10],
GITEA__database__CONN_MAX_LIFETIME: ["database-connection-lifetime", "5m"],
};
for (const [name, [key, def]] of Object.entries(placed)) {
assert.ok(lines.includes(`${name}=\${config:${key}}`), `server.env does not place ${key} at ${name}`);
assert.equal(manifest.config[key].default, def);
assert.equal(manifest.config[key].trusted, false);
assert.ok(manifest.config[key].description.includes("100 connections"));
}
assert.equal(manifest.config["database-max-open-connections"].minimum, 1);
const server = manifest.resources.find((r: { id: string }) => r.id === "server");
assert.deepEqual(server["restart-on"], ["server-env"]);
});
+1
View File
@@ -1,6 +1,7 @@
{
"module": "lab",
"version": "1",
"moved": true,
"capabilities": [
"container-runtime",
"virtualisation"
+2 -1
View File
@@ -125,10 +125,11 @@ function renderedForgeFiles(forge: string): string {
test("the lab requires the forge account for its clones, and holds no registry credential", () => {
const m = JSON.parse(readFileSync(join(labDir, "module.json"), "utf8")) as {
requires: string[]; contributes?: unknown; secrets?: unknown; resources: { id: string; path?: string; template?: string; package?: string }[];
moved?: boolean; requires: string[]; contributes?: unknown; secrets?: unknown; resources: { id: string; path?: string; template?: string; package?: string }[];
build: { artifacts: { name: string; env?: Record<string, string> }[] };
};
assert.ok(m.requires.includes("forge-account") && m.requires.includes("npm-package-registry"));
assert.equal(m.moved, true, "its files are templates filled from the forge-account binding by the node-engine, which a module that has moved asks for");
assert.equal(m.contributes, undefined, "npm-package-registry reads are anonymous: nothing is contributed");
assert.equal(m.secrets, undefined, "no registry secret is sealed to the lab");
const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {};
+4
View File
@@ -49,6 +49,10 @@ already gone is no error, one a running step holds is refused); its caller calls
failed fetch removes its own workspace. A workspace no caller released is removed two hours after its last use (ADR
0509), and the removal is said in the log as the caller's fault.
The programs this module builds from a commit and keeps in its cache (composers of compose asks, checking programs of run
asks) are read by `build_go_kept` (kind, key, size, last use) and removed by `build_go_kept_drop {kind, key}`; one no ask used
for 30 days is swept with the workspaces (issue 1921).
A pin on a pull request's head (issue 1148): when `go mod download` fails on a `git.novox.be/novox/*` pseudo-version, the
fetch looks for that commit among the forge's `refs/pull/*/head`, places the repository at it under the workspace's
`pinned/beside/` as a plain directory, and hands it to Go (the fetch's commands, and the build with `GOPROXY=off`) through a
@@ -71,3 +71,11 @@ the command is mesh-build-node's type-check line without a tsconfig.json:
node <typescript>/bin/tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . ok.ts seat.ts
and the file is its stdout followed by its stderr.
## passing/ — a runner's words when the network did not answer (novox/hq issue 1748, ADR 0664 §1)
Not captured by running: a network cannot be cut on demand in a test. Each file holds the words a runner wrote when a
build step met the fault on the mesh (the journal of ace on 2026-11-09 for ssh and go, the forge's sshd penalty; npm's
error codes and buildah's pull failure as those programs print them), and for each a negative case, a failure of the
source whose words are never a network's (an unknown revision, a checksum, a missing package, a failed RUN). Name:
`<runner>-<case>.txt`; the kit's table (conformance.go, PassingCases) says which are transport faults.
@@ -0,0 +1 @@
Error: building at STEP "RUN false": exit status 1
@@ -0,0 +1 @@
Error: creating build container: initializing source docker://alpine: pinging container registry registry-1.docker.io: Get "https://registry-1.docker.io/v2/": dial tcp: lookup registry-1.docker.io on 10.10.0.3:53: i/o timeout
@@ -0,0 +1,2 @@
Connection closed by 10.10.0.1 port 222
fatal: Could not read from remote repository.
@@ -0,0 +1,6 @@
kex_exchange_identification: read: Connection reset by peer
Connection reset by 10.10.0.1 port 222
fatal: Could not read from remote repository.
Please make sure you have the correct access rights
and the repository exists.
@@ -0,0 +1 @@
fatal: couldn't find remote ref 0123456789abcdef0123456789abcdef01234567
@@ -0,0 +1,2 @@
ssh: Could not resolve hostname mesh-forge: Temporary failure in name resolution
fatal: Could not read from remote repository.
@@ -0,0 +1 @@
{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"verifying module: checksum mismatch"}
@@ -0,0 +1 @@
{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"git.novox.be/novox/mesh-sdk/go@abc: dial tcp 10.10.0.1:5100: i/o timeout"}
@@ -0,0 +1 @@
{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"git.novox.be/novox/mesh-sdk/go@abc: dial tcp: lookup git.novox.be on 10.10.0.3:53: read udp 10.10.0.3:42292->10.10.0.3:53: i/o timeout"}
@@ -0,0 +1 @@
{"Path":"git.novox.be/novox/mesh-sdk/go","Error":"invalid version: unknown revision abc"}
@@ -0,0 +1,2 @@
npm error code E404
npm error 404 Not Found - GET https://git.novox.be/api/packages/novox/npm/@novox%2fx
@@ -0,0 +1,3 @@
npm error code EAI_AGAIN
npm error syscall getaddrinfo
npm error errno EAI_AGAIN
@@ -0,0 +1,4 @@
npm error code ECONNRESET
npm error syscall read
npm error errno -104
npm error network aborted
@@ -12,6 +12,7 @@ package conformance
import (
"embed"
"fmt"
"slices"
"strings"
"testing"
@@ -199,3 +200,80 @@ func describe(failed []buildstep.Failure) string {
}
func quote(s string) string { return fmt.Sprintf("%q", s) }
// The runners whose network failures the kit holds words of (Passing).
const (
GitFetch = "git fetch"
GoDownload = "go mod download"
NpmCI = "npm ci"
BuildahBud = "buildah bud"
)
// PassingCase is the words of one runner and whether they are a transport fault: a network that did not answer, which the
// bus gives the ask again for (novox/hq ADR 0664 §1), or a failure of the source, which fails the step at once.
type PassingCase struct {
Name, Runner string
Transport bool
}
// PassingCases are the kit's cases of the network's failures (captured/passing).
var PassingCases = []PassingCase{
{"git-fetch-kex", GitFetch, true}, {"git-fetch-closed", GitFetch, true}, {"git-fetch-resolve", GitFetch, true}, {"git-fetch-noref", GitFetch, false},
{"go-download-lookup", GoDownload, true}, {"go-download-dial", GoDownload, true}, {"go-download-unknown", GoDownload, false}, {"go-download-checksum", GoDownload, false},
{"npm-ci-econnreset", NpmCI, true}, {"npm-ci-eaiagain", NpmCI, true}, {"npm-ci-404", NpmCI, false},
{"buildah-bud-timeout", BuildahBud, true}, {"buildah-bud-run", BuildahBud, false},
}
// Failed is a build module's own path from a runner's words to the error its step ends with: the same code its step runs on
// a failed command, fed the words of a runner's failed run.
type Failed func(runner, words string) error
// Passing runs every case of the runners through failed, each as a subtest, and fails a build module whose step does not
// say a network's failure as a buildstep.Passing (the bus then gives the ask again) or says a failure of the source as one.
// A build module names the runners its steps use; the case's words are in captured/passing.
func Passing(t *testing.T, runners []string, failed Failed) {
t.Helper()
ran := 0
for _, c := range PassingCases {
if !slices.Contains(runners, c.Runner) {
continue
}
ran++
t.Run(c.Name, func(t *testing.T) {
if problem := judgePassing(c, failed); problem != "" {
t.Error(problem)
}
})
}
if ran == 0 {
t.Fatalf("the kit holds no passing case of the runners %v", runners)
}
}
// JudgePassing is the problems of failed over every case of the runners: what Passing fails a build module for.
func JudgePassing(runners []string, failed Failed) []string {
var out []string
for _, c := range PassingCases {
if slices.Contains(runners, c.Runner) {
if p := judgePassing(c, failed); p != "" {
out = append(out, p)
}
}
}
return out
}
func judgePassing(c PassingCase, failed Failed) string {
words, err := captured.ReadFile("captured/passing/" + c.Name + ".txt")
if err != nil {
return err.Error()
}
got := failed(c.Runner, string(words))
switch {
case got == nil:
return c.Name + ": the step passed on a failed run"
case buildstep.IsPassing(got) != c.Transport:
return fmt.Sprintf("%s: the step ends %q, a transport fault %v, want %v", c.Name, got, buildstep.IsPassing(got), c.Transport)
}
return ""
}
@@ -3,6 +3,7 @@ package conformance
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -23,6 +24,19 @@ func TestTheFailureContract(t *testing.T) {
})
})
// ADR 0664 §1 (issue 1748): the shared wrapper keeps the passing cases; a step that never says a network's failure as a
// transport fault, or says every failure as one, is refused.
t.Run("the passing cases", func(t *testing.T) {
all := []string{GitFetch, GoDownload, NpmCI, BuildahBud}
Passing(t, all, func(_, words string) error { return buildstep.Transport(errors.New(words)) })
if p := JudgePassing(all, func(_, words string) error { return errors.New(words) }); len(p) != 8 {
t.Errorf("a step that wraps nothing: %d problems, want 8 (one per transport case):\n%s", len(p), strings.Join(p, "\n"))
}
if p := JudgePassing(all, func(_, words string) error { return &sdkstep.Passing{Err: errors.New(words)} }); len(p) != 5 {
t.Errorf("a step that wraps everything: %d problems, want 5 (one per source failure):\n%s", len(p), strings.Join(p, "\n"))
}
})
// 1890/AC7: the SDK's start sweep keeps the left-running case, and the kit refuses a sweep that ends nothing or ends
// everything.
t.Run("the left-running case", func(t *testing.T) {
+13 -2
View File
@@ -9,6 +9,7 @@ import (
"regexp"
"strings"
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -52,6 +53,16 @@ var (
refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`)
)
// gitFailure is a failed git command said in git's own words; one whose words are a network's (a lookup, a timeout, ssh's key
// exchange cut by a forge's per-source penalty) is a sdkstep.Passing, so the ask is given again after a delay (ADR 0664 §1).
func gitFailure(verb, said string) error {
err := fmt.Errorf("git %s: %s", verb, said)
if sdkstep.PassingCause(said) {
return &sdkstep.Passing{Err: err}
}
return err
}
// FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached
// (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh://<ssh name>/<owner>/<repo>.git, the ssh name being the
// one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names
@@ -72,7 +83,7 @@ func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit,
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
r := run(ctx, env, "git", full...)
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("git %s: %s", args[0], dforgekey.Said(r.Err+"\n"+r.Stderr))
return "", gitFailure(args[0], dforgekey.Said(r.Err+"\n"+r.Stderr))
}
return r.Stdout, nil
}
@@ -133,7 +144,7 @@ func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, comm
if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C
verb = args[2]
}
return fmt.Errorf("git %s: %s", verb, dforgekey.Said(r.Err+"\n"+r.Stderr))
return gitFailure(verb, dforgekey.Said(r.Err+"\n"+r.Stderr))
}
return nil
}
@@ -2,6 +2,7 @@ package buildstep
import (
"context"
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
"strings"
"testing"
@@ -93,6 +94,28 @@ func TestFetchCommitFallsBackToThePullRef(t *testing.T) {
if err := FetchCommit(context.Background(), h.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err == nil {
t.Error("a commit that is not in the ref was accepted")
}
// ADR 0664 §1 (issue 1748): the words a forge's sshd penalty leaves (captured on ace, 2026-11-09) are a transport fault the bus
// gives the ask again for; a missing commit is not.
for _, c := range []struct {
said string
passing bool
}{
{"kex_exchange_identification: read: Connection reset by peer\nfatal: Could not read from remote repository.", true},
{"Connection closed by 10.10.0.1 port 222", true},
{"ssh: Could not resolve hostname mesh-forge: Temporary failure in name resolution", true},
{"fatal: couldn't find remote ref " + sha, false},
} {
x := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.Contains(l, " fetch ") {
return forgekey.Ran{Status: 128, Stderr: c.said}
}
return forgekey.Ran{}
}}
if err := FetchCommit(context.Background(), x.run, "/w", "ssh://mesh-forge/o/r.git", sha, ""); err == nil || sdkstep.IsPassing(err) != c.passing {
t.Errorf("%q: %v, passing %v, want %v", c.said, err, err != nil && sdkstep.IsPassing(err), c.passing)
}
}
}
// 1474/AC3: HEAD differs from the commit after the checkout: an error naming both.
+1
View File
@@ -3,6 +3,7 @@ module git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000
+14 -8
View File
@@ -27,16 +27,19 @@ const Bubblewrap = "bwrap"
// - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries,
// certificates, time zone), with a hosts file naming only the loopback;
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
// downloaded) read-only;
// downloaded) read-only, and the ReadOnlyInside ones (inside the Workspace) read-only over it;
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
// (WriteCaches), so a candidate's code never changes what a later build reads.
type Isolation struct {
Workspace string
Writable []string
ReadOnly []string
Caches []string
WriteCaches bool
Env []string
Workspace string
Writable []string
ReadOnly []string
// ReadOnlyInside are directories inside the Workspace or a Writable one bound read-only after them, so they stay
// read-only (a later bind of a path wins; a ReadOnly one inside the Workspace would be covered by it).
ReadOnlyInside []string
Caches []string
WriteCaches bool
Env []string
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
Hosts string
}
@@ -91,7 +94,7 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
for _, group := range []struct {
what string
dirs []string
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} {
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"read-only directory", iso.ReadOnlyInside}, {"cache", iso.Caches}} {
for _, d := range group.dirs {
if err := checkDir(group.what, d); err != nil {
return nil, err
@@ -118,6 +121,9 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
for _, d := range iso.Writable {
argv = append(argv, "--bind", d, d)
}
for _, d := range iso.ReadOnlyInside {
argv = append(argv, "--ro-bind", d, d)
}
if iso.Hosts != "" {
argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts")
}
+1 -1
View File
@@ -56,7 +56,7 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
r := run(ctx, env, "git", full...)
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("git %s: %s", args[0], dforgekey.Said(r.Err+"\n"+r.Stderr))
return "", gitFailure(args[0], dforgekey.Said(r.Err+"\n"+r.Stderr))
}
return r.Stdout, nil
}

Some files were not shown because too many files have changed in this diff Show More