Establish the Ting/Jostoph integration perimeter #2

Merged
larandar merged 6 commits from perimeter/steward into main 2026-08-07 20:12:35 +00:00
Owner

Closes #1. Depends on lar.ad/jostoph-rs#2.

In scope, as built

  • Jostoph CLImanifest, check (exits 1 on a denial, so it composes with a shell gate), serve.
  • Nix overlay packaging the CLI onto PATH, plus the crystallized policy and the webhook declaration as separate outputs.
  • Forgejo configuration declared through the overlaynix/forgejo.nix renders the webhook and a reconcile script.
  • systemd webhook listener and service wiringnix/module.nix.
  • Ting's rules compiled into the CLI, parameters crystallized in a Nix derivation.
  • The answering surface. Gate-rule execution is the next extension.

Rules are Datalog; parameters are Nix

src/policy.rs is an ascent! program. nix/policy.nix holds what the estate requires — sections, protected refs, stewards — crystallized into a JSON derivation and baked in with include_str!.

Change Where
What the estate requires nix/policy.nix
How requirement is judged src/policy.rs

Neither can be done by accident in the other's name. policy/ting.json is the checked-in development default; nix flake check fails if it has drifted from the derivation, so the two cannot silently diverge.

The program hands its output relations back verbatim — precedence is the substrate's, in Datalog, and is not restated here.

Currently required: an opened issue or PR declares In scope, Out of scope, Boundary, Acceptance (an empty body is its own denial); refs/heads/main is not pushed to directly by anyone; an unrecognized actor is gated.

Notable decisions

  • No unsigned webhook mode. An unauthenticated endpoint lets anyone on the network put facts in front of the steward, and every ruling after that is worthless. The body is taken as raw bytes because the MAC covers the bytes as sent.
  • The steward holds no Forgejo token. It declares effects and performs none, so it has nothing to authenticate as. Only the separate reconcile unit holds one, and it reads it from the credential directory — a token on a command line is a token in every process listing.
  • listen defaults to loopback. /v1/audit is a record of everything the steward has been told.
  • Reconcile is additive-then-idempotent and never deletes hooks it did not create. A deployment tool that removes unrecognized webhooks is one bad eval away from severing an unrelated integration.
  • vendor/jostoph is a path dependency populated from the flake input, not a Cargo git dependency, so cargo build --locked --offline works in the Nix sandbox with no forge credentials.

Verification

19 tests (policy, webhook signature handling, event translation). nix flake check runs the build, the package's tests, a postInstall manifest check, a NixOS module evaluation producing both units, and the policy-drift check. End-to-end: a real server accepted a signed delivery, denied it with four named missing sections and four declared (unperformed) forgejo.comment effects, refused an unsigned one with 401, and served it all back from /v1/audit.

Known gap

No flake.lock is committed. lar.ad/jostoph-rs is private, so Nix cannot fetch the input without a credential and the lock cannot be generated. The README documents the ~/.config/nix/netrc setup; the lock should be committed once that is in place (or once the substrate repo is readable). Everything here was verified with --override-input jostoph-rs path:../jostoph-rs.

🤖 Generated with Claude Code

Closes #1. Depends on lar.ad/jostoph-rs#2. ## In scope, as built - **Jostoph CLI** — `manifest`, `check` (exits 1 on a denial, so it composes with a shell gate), `serve`. - **Nix overlay** packaging the CLI onto PATH, plus the crystallized policy and the webhook declaration as separate outputs. - **Forgejo configuration declared through the overlay** — `nix/forgejo.nix` renders the webhook and a reconcile script. - **systemd webhook listener and service wiring** — `nix/module.nix`. - **Ting's rules compiled into the CLI, parameters crystallized in a Nix derivation.** - **The answering surface.** Gate-rule execution is the next extension. ## Rules are Datalog; parameters are Nix `src/policy.rs` is an `ascent!` program. `nix/policy.nix` holds what the estate *requires* — sections, protected refs, stewards — crystallized into a JSON derivation and baked in with `include_str!`. | Change | Where | | --- | --- | | What the estate requires | `nix/policy.nix` | | How requirement is judged | `src/policy.rs` | Neither can be done by accident in the other's name. `policy/ting.json` is the checked-in development default; `nix flake check` fails if it has drifted from the derivation, so the two cannot silently diverge. The program hands its output relations back verbatim — precedence is the substrate's, in Datalog, and is not restated here. Currently required: an opened issue or PR declares **In scope**, **Out of scope**, **Boundary**, **Acceptance** (an empty body is its own denial); `refs/heads/main` is not pushed to directly *by anyone*; an unrecognized actor is gated. ## Notable decisions - **No unsigned webhook mode.** An unauthenticated endpoint lets anyone on the network put facts in front of the steward, and every ruling after that is worthless. The body is taken as raw bytes because the MAC covers the bytes as sent. - **The steward holds no Forgejo token.** It declares effects and performs none, so it has nothing to authenticate as. Only the separate reconcile unit holds one, and it reads it from the credential directory — a token on a command line is a token in every process listing. - **`listen` defaults to loopback.** `/v1/audit` is a record of everything the steward has been told. - **Reconcile is additive-then-idempotent and never deletes hooks it did not create.** A deployment tool that removes unrecognized webhooks is one bad eval away from severing an unrelated integration. - **`vendor/jostoph` is a path dependency** populated from the flake input, not a Cargo git dependency, so `cargo build --locked --offline` works in the Nix sandbox with no forge credentials. ## Verification 19 tests (policy, webhook signature handling, event translation). `nix flake check` runs the build, the package's tests, a `postInstall` manifest check, a NixOS module evaluation producing both units, and the policy-drift check. End-to-end: a real server accepted a signed delivery, denied it with four named missing sections and four declared (unperformed) `forgejo.comment` effects, refused an unsigned one with 401, and served it all back from `/v1/audit`. ## Known gap **No `flake.lock` is committed.** `lar.ad/jostoph-rs` is private, so Nix cannot fetch the input without a credential and the lock cannot be generated. The README documents the `~/.config/nix/netrc` setup; the lock should be committed once that is in place (or once the substrate repo is readable). Everything here was verified with `--override-input jostoph-rs path:../jostoph-rs`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ting's Forgejo steward: the estate-specific projection of jostoph-rs.

Rules are the ascent! Datalog program in src/policy.rs; their parameters
-- required sections, protected refs, stewards -- are nix/policy.nix,
crystallized into a JSON derivation and baked into the binary. Changing
what the estate requires is a Nix edit; changing how requirement is
judged is a code change. nix flake check fails if the checked-in
policy/ting.json has drifted from the derivation.

The program hands its output relations back verbatim: precedence is the
substrate's, in Datalog, not restated here.

Forgejo integration is a webhook listener that verifies the delivery
signature, translates the wire format into a substrate event, and rules.
Unsigned deliveries are refused with no way to turn that off.

Nix: an overlay packaging the CLI, the crystallized policy, and the
webhook declaration; a NixOS module wiring a hardened DynamicUser unit
with secrets via systemd credentials; a separate reconcile unit that
holds the only Forgejo token, since the steward declares effects and
performs none.

The answering surface only. Gate-rule execution is the next extension.

Closes #1

Co-Authored-By: Claude Opus 5 <[email protected]>
Standing was a flat list of logins. It is now held by groups and flows
downward from steward_root:

    stewards
    └── estate          <- larandar
        └── agents      <- agent.odin

nix/policy.nix declares only the direct nesting; `contains` is tagged
#[ds(ascent_byods_rels::trrel)], so agents-under-stewards follows from
the two declared edges. No closure rule is written, so none can be
forgotten when the hierarchy grows. Dropping the tag makes
standing_reaches_through_nested_groups fail, so the tests distinguish it.

Standing flows one way: a member of a group that merely contains the
root holds nothing, and a group outside the hierarchy confers nothing.

A BYODS-backed relation cannot be assigned from Rust, so the declared
edges arrive in the plain group_nests relation and are copied in by one
rule. nix/policy.nix refuses at eval time to reference a group it has
not declared -- otherwise a typo would silently strip someone's standing
rather than fail. Policy schema bumped to 2.

The denial for a missing section and the comment explaining it are now
one rule with two heads over a shared `let reason` binding, so the
(key, reason) join that ties an effect to its conclusion holds by
construction instead of because the same string was written twice.

Co-Authored-By: Claude Opus 5 <[email protected]>
nix/forgejo.nix Outdated
@ -0,0 +51,4 @@
# absent and updates it if present, and never deletes hooks it did not create.
# A deployment tool that removes unrecognized webhooks is one bad eval away
# from severing an unrelated integration.
reconcile =
Owner

The tool of the trade in the estate are not impure nix but either nixops4 or deploy-rs

The tool of the trade in the estate are not impure nix but either nixops4 or deploy-rs
Review: the estate's tools are deploy-rs and nixops4, not impure nix.
The activation-time curl/jq reconcile script and its systemd oneshot are
removed, along with the Forgejo token they needed.

nix/forgejo.nix is now declaration only -- nothing reaches the network,
nothing mutates the forge. The rendered spec lands at
/etc/jostoph/forgejo-webhook.json so the host can be asked what it
believes Forgejo ought to be sending it.

Reconciling the forge against that declaration is the steward's, per
Ratification law 12: forge-side conformance -- protections, labels, PR
defaults, webhooks -- read via API and compared against the declared
spec, reached at birth through `just forgejo`. The layering leaves no
other seat: deploy-rs owns convergence, nixops4 owns entities with
irreplaceable state, and a webhook registration is neither. The secret
it is signed with *is* such an entity, so minting and rotation are a
nixops4 concern; this module only names the file it arrives in.

A flake check now fails the build if any unit this module generates
grows a curl or a FORGEJO_TOKEN, so the boundary cannot erode quietly.

Refs #1

Co-Authored-By: Claude Opus 5 <[email protected]>
Author
Owner

Fixed in fd8d47a. You were right, and reading the law made it clearer than "not impure nix" alone: there was no seat for that script anywhere.

Removed: the activation-time curl/jq reconcile script, its jostoph-forgejo-webhook systemd oneshot, and the tokenFile/reconcileWebhook options. The deployment holds no Forgejo token at all now.

Kept: nix/forgejo.nix as declaration only. Nothing reaches the network, nothing mutates the forge. The rendered spec lands at /etc/jostoph/forgejo-webhook.json, so the host can be asked what it believes Forgejo ought to be sending it — and that is the end of it.

Where reconciliation actually belongs, per the layering in flaky-mesh#22 and law 12:

  • deploy-rs owns convergence of machine closures — a webhook is not a closure.
  • nixops4 owns entities with irreplaceable state — comment 343's test is "a resource type exists iff its entity has irreplaceable state." A webhook registration fails that test: it is re-creatable from this declaration at any time. The secret it is signed with passes it (key material, mint = create, rotation = update), so minting and rotating that is a nixops4 resource concern. This module only names the file it arrives in.
  • The steward owns what is left, and law 12 names it outright: "Forge-side conformance is the steward. Branch protections, labels, PR defaults, webhooks — read via API, compared against the declared spec, remediated or (for anything destructive) alarmed." Birth via just forgejo, reconciled forever after.

So the honest answer to "nixops4 or deploy-rs" turned out to be neither — this belongs to the steward, and the issue asked for the webhook to be declared through the overlay, which is exactly what is left.

A nix flake check assertion now fails the build if any unit this module generates grows a curl or a FORGEJO_TOKEN, so the boundary cannot erode quietly.


Three things I found while reading the law that are not addressed by this commit, flagged rather than silently fixed:

  1. This PR violates law 9's branch naming. It is perimeter/steward, not tango/1-<slug>, and jostoph-rs#2 is perimeter/substrate. My miss — the work was dispatched after ratification, so the transition clause does not cover it. Renaming means closing and reopening both PRs (Forgejo cannot retarget a head), which costs this review thread. Happy to do it on your word.

  2. Both PRs target main because neither repo has a fastlane. Same position agent.teyla recorded for flaky-mesh PR #23 — "could only have targeted main, and did". Neither Ting/Jostoph nor lar.ad/jostoph-rs has had the law-12 birth recipe run: no fastlane, no law-13 labels, and Ting does not appear in law 8's org/seat table at all.

  3. A possible specification conflict, worth a ruling. Issue #1 puts a "systemd webhook listener" in scope, and that is what I built. But flaky-mesh#22 comment 336 ratified the opposite for the steward: "the steward is a polling conformance worker, not a webhook listener — all its transitions are derivable from API state, and a poller that dies re-derives on restart (a dead listener silently misses events)." If Jostoph is that steward, the listener is the wrong shape and the rationale against it is exactly right. If Ting's steward is a different animal, no conflict. Under law 13 a spec miss is a new linked ticket and never a bounce, so I have not touched it — say the word and I will file one.

Fixed in `fd8d47a`. You were right, and reading the law made it clearer than "not impure nix" alone: there was no seat for that script anywhere. **Removed:** the activation-time `curl`/`jq` reconcile script, its `jostoph-forgejo-webhook` systemd oneshot, and the `tokenFile`/`reconcileWebhook` options. The deployment holds no Forgejo token at all now. **Kept:** `nix/forgejo.nix` as declaration only. Nothing reaches the network, nothing mutates the forge. The rendered spec lands at `/etc/jostoph/forgejo-webhook.json`, so the host can be asked what it believes Forgejo ought to be sending it — and that is the end of it. Where reconciliation actually belongs, per the layering in flaky-mesh#22 and law 12: - **deploy-rs** owns convergence of machine closures — a webhook is not a closure. - **nixops4** owns entities with irreplaceable state — comment 343's test is *"a resource type exists iff its entity has irreplaceable state."* A webhook **registration** fails that test: it is re-creatable from this declaration at any time. The **secret** it is signed with passes it (key material, mint = create, rotation = update), so minting and rotating that is a nixops4 resource concern. This module only names the file it arrives in. - **The steward** owns what is left, and law 12 names it outright: *"Forge-side conformance is the steward. Branch protections, labels, PR defaults, webhooks — read via API, compared against the declared spec, remediated or (for anything destructive) alarmed."* Birth via `just forgejo`, reconciled forever after. So the honest answer to "nixops4 or deploy-rs" turned out to be neither — this belongs to the steward, and the issue asked for the webhook to be *declared* through the overlay, which is exactly what is left. A `nix flake check` assertion now fails the build if any unit this module generates grows a `curl` or a `FORGEJO_TOKEN`, so the boundary cannot erode quietly. --- Three things I found while reading the law that are **not** addressed by this commit, flagged rather than silently fixed: 1. **This PR violates law 9's branch naming.** It is `perimeter/steward`, not `tango/1-<slug>`, and jostoph-rs#2 is `perimeter/substrate`. My miss — the work was dispatched after ratification, so the transition clause does not cover it. Renaming means closing and reopening both PRs (Forgejo cannot retarget a head), which costs this review thread. Happy to do it on your word. 2. **Both PRs target `main` because neither repo has a `fastlane`.** Same position agent.teyla recorded for flaky-mesh PR #23 — "could only have targeted `main`, and did". Neither `Ting/Jostoph` nor `lar.ad/jostoph-rs` has had the law-12 birth recipe run: no fastlane, no law-13 labels, and `Ting` does not appear in law 8's org/seat table at all. 3. **A possible specification conflict, worth a ruling.** Issue #1 puts a "systemd webhook listener" in scope, and that is what I built. But flaky-mesh#22 comment 336 ratified the opposite for the steward: *"the steward is a **polling conformance worker**, not a webhook listener — all its transitions are derivable from API state, and a poller that dies re-derives on restart (a dead listener silently misses events)."* If Jostoph is that steward, the listener is the wrong shape and the rationale against it is exactly right. If Ting's steward is a different animal, no conflict. Under law 13 a spec miss is a new linked ticket and never a bounce, so I have not touched it — say the word and I will file one.
Restores the reconciliation I removed a commit too eagerly. `just
forgejo` predates the nixops4/deploy-rs decision, so law 12's birth
recipe is not where this lives; convergence is deploy-rs's, and the call
is idempotent, so the recipe is 'always call it' rather than a state
machine that remembers what it did last time.

lib.forgejoWebhookProfile builds a deploy-rs profile via activate.custom.
deploy --dry-activate reports what would change and touches nothing.

What does not come back: the systemd oneshot, and any forge token in the
system closure. The NixOS module still only declares -- it renders the
spec to /etc/jostoph/forgejo-webhook.json and stops -- and the flake
check still fails the build if a unit it generates grows a curl or a
FORGEJO_TOKEN.

Identity is not created here. Token and webhook secret are nixops4's to
mint, and arrive as files; secretFile is deliberately the same file the
service reads so the two cannot drift. The secret is spliced in at run
time rather than baked into a world-readable store path, and a check
asserts it never reaches the store.

Hooks it did not create are never touched, and a check fails if a DELETE
appears in the script. Verified --dry-run against the live forge: it
authenticated, listed the hooks, reported 'would create webhook on
Ting/Jostoph', and mutated nothing.

Refs #1

Co-Authored-By: Claude Opus 5 <[email protected]>
Author
Owner

Correction to my last comment — I over-read law 12. just forgejo predates the nixops4/deploy-rs ruling, so citing it as the seat for this was wrong. Reconciliation is back in 64e171b, as deploy-rs.

deploy.nodes.bifrost.profiles.jostoph-webhook =
  jostoph.lib.forgejoWebhookProfile {
    system = "x86_64-linux";
    forge = "https://jo.et0.pw";
    url = "https://jostoph.ting.example/webhook/forgejo";
    repositories = [ "Ting/Jostoph" ];
    tokenFile = "/run/secrets/jostoph-forgejo-token";
    secretFile = "/run/secrets/jostoph-webhook";   # the file the service reads
  };

Built on deploy-rs.lib.${system}.activate.custom. Because the call is idempotent the recipe is always call it, not a state machine that tries to remember what it did last time. deploy --dry-activate wires through to --dry-run, which reports what would change and touches nothing.

What did not come back: the systemd oneshot, and any forge token in the system closure. The NixOS module still only declares — renders the spec to /etc/jostoph/forgejo-webhook.json and stops — and the nix flake check assertion still fails the build if a unit it generates grows a curl or a FORGEJO_TOKEN.

Identity is not created here, per the layering: the token and the webhook secret are nixops4's to mint and arrive as files. secretFile is deliberately the same file the service reads, so the two cannot drift apart. The secret is spliced in at run time rather than baked into a store path, and a check asserts it never reaches the store.

Forgejo has no upsert for hooks, so create-or-patch is the minimal idempotent form. Hooks it did not create are never touched, and a check fails the build if a DELETE ever appears in the script.

Verified --dry-run against the live forge with a real token: it authenticated, listed the hooks on Ting/Jostoph, reported would create webhook on Ting/Jostoph, and mutated nothing. writeShellApplication runs shellcheck, and a check asserts the profile builds and is executable — so a broken script fails nix flake check rather than mid-deploy.

The three items at the end of my previous comment still stand, unchanged and still yours to call: the law-9 branch names, the missing fastlane on both repos, and whether the webhook-listener shape conflicts with comment 336's polling steward.

Correction to my last comment — I over-read law 12. `just forgejo` predates the nixops4/deploy-rs ruling, so citing it as the seat for this was wrong. Reconciliation is back in `64e171b`, as deploy-rs. ```nix deploy.nodes.bifrost.profiles.jostoph-webhook = jostoph.lib.forgejoWebhookProfile { system = "x86_64-linux"; forge = "https://jo.et0.pw"; url = "https://jostoph.ting.example/webhook/forgejo"; repositories = [ "Ting/Jostoph" ]; tokenFile = "/run/secrets/jostoph-forgejo-token"; secretFile = "/run/secrets/jostoph-webhook"; # the file the service reads }; ``` Built on `deploy-rs.lib.${system}.activate.custom`. Because the call is idempotent the recipe is *always call it*, not a state machine that tries to remember what it did last time. `deploy --dry-activate` wires through to `--dry-run`, which reports what would change and touches nothing. **What did not come back:** the systemd oneshot, and any forge token in the system closure. The NixOS module still only declares — renders the spec to `/etc/jostoph/forgejo-webhook.json` and stops — and the `nix flake check` assertion still fails the build if a unit it generates grows a `curl` or a `FORGEJO_TOKEN`. **Identity is not created here**, per the layering: the token and the webhook secret are nixops4's to mint and arrive as files. `secretFile` is deliberately the same file the service reads, so the two cannot drift apart. The secret is spliced in at run time rather than baked into a store path, and a check asserts it never reaches the store. Forgejo has no upsert for hooks, so create-or-patch is the minimal idempotent form. Hooks it did not create are never touched, and a check fails the build if a `DELETE` ever appears in the script. Verified `--dry-run` against the live forge with a real token: it authenticated, listed the hooks on `Ting/Jostoph`, reported `would create webhook on Ting/Jostoph`, and mutated nothing. `writeShellApplication` runs shellcheck, and a check asserts the profile builds and is executable — so a broken script fails `nix flake check` rather than mid-deploy. The three items at the end of my previous comment still stand, unchanged and still yours to call: the law-9 branch names, the missing `fastlane` on both repos, and whether the webhook-listener shape conflicts with comment 336's polling steward.
Reverts the deploy-rs profile. The shell script was the smell: a Forgejo
hook carries a forge-assigned id, so its identity is state, and a script
has nowhere to keep it -- mine re-derived it every run by listing hooks
and matching on URL, a state lookup standing in for state management.
That is exactly nixops4's requireState case.

The provider already exists in flight: lar.ad/flake-ops#3, in progress,
covering org/repo/branch/branch_protection/label. Law 12 named a
`just forgejo` recipe; the owner ruled on 2026-08-05 that it should not
become a recipe. Building convergence here would duplicate that work
with a worse mechanism.

So this repo declares and stops. nix/forgejo.nix renders the webhook in
Forgejo's own API shape, the module writes it to
/etc/jostoph/forgejo-webhook.json, and the check still fails the build if
a unit grows a curl or a FORGEJO_TOKEN.

Note for the provider: flake-ops#3's declared resource types do not
include a webhook, though law 12 lists webhooks beside protections and
labels. Raised on that ticket rather than assumed here.

Refs #1

Co-Authored-By: Claude Opus 5 <[email protected]>
agent.odin force-pushed perimeter/steward from 52bd0a92ca to 30bddb6f4e 2026-08-07 16:16:00 +00:00 Compare
larandar deleted branch perimeter/steward 2026-08-07 20:12:35 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Ting/Jostoph!2
No description provided.