Ting's Forgejo steward: rules, answers, and an audit trail.
  • Rust 65.6%
  • Nix 34.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
agent.teyla bb98bb9151 The fastlane manifest: where development actually happens (#6)
Owner direction, 2026-08-10 (in flake-ops' kanidm session): the estate's development manifest lives HERE, with the steward, beside the `.doctor.just` currently in transit — not in a repo of its own. Both halves ship in this PR, dotfiles like the seams that read them.

- **`.fastlane.toml`** — the canonical map every repo's seam fetches raw (anonymous https) and caches beside its justfile: each midgard-pinned flake input named to the development repo the source org actually works on.
- **`.fastlane.just`** — the seam itself, a standalone justfile with one recipe. A consuming repo picks it up two ways: `mod? fastlane ".fastlane.just"` for direct use (`just fastlane::assemble`), and a variable — `` flake_override := if path_exists(...) == "true" { `just -f .fastlane.just assemble` } else { "" } `` — whose flags whatever runs nix splices in. PRESENCE IS THE TOGGLE: file in place → evaluations run against the manifest's dev repos at `ref` (default fastlane, `ref=<other>` per invocation); file absent → the published world. Deploys run from the lock regardless.

First consumer: lar.ad/flake-ops, wired exactly this way; its fetch falls back to the cached copy until this merges (fallback path exercised). The `midgard/fastlane` repo briefly created for this purpose today is superseded by this PR and should be deleted (the agent toolset has no repo-delete verb).

Reviewed-on: Ting/Jostoph#6
2026-08-10 05:49:40 +00:00
nix Leave webhook convergence to the Forgejo provider 2026-08-07 16:07:16 +02:00
policy Hold steward standing in nested groups, closed by BYODS 2026-08-07 14:39:20 +02:00
src Hold steward standing in nested groups, closed by BYODS 2026-08-07 14:39:20 +02:00
.doctor.just The doctor: one .doctor.just, distributed from the steward (#8) 2026-08-10 05:48:36 +00:00
.fastlane.just The fastlane manifest: where development actually happens (#6) 2026-08-10 05:49:40 +00:00
.fastlane.toml The fastlane manifest: where development actually happens (#6) 2026-08-10 05:49:40 +00:00
.gitignore Establish the Ting/Jostoph integration perimeter 2026-08-07 14:05:30 +02:00
Cargo.lock Hold steward standing in nested groups, closed by BYODS 2026-08-07 14:39:20 +02:00
Cargo.toml Hold steward standing in nested groups, closed by BYODS 2026-08-07 14:39:20 +02:00
flake.nix Leave webhook convergence to the Forgejo provider 2026-08-07 16:07:16 +02:00
README.md docs: name the declaration that supplies the webhook 2026-08-07 18:15:58 +02:00

Jostoph

Ting's Forgejo steward: it watches the forge, rules on what happens there against the estate's declared policy, and keeps an audit trail of every ruling.

Built on lar.ad/jostoph-rs, which supplies the substrate — the service lifecycle, the Datalog deduction engine, dispatch, and resolution. This repository supplies the parts that are Ting's: the rules, the Forgejo integration, and the deployment.

Perimeter

In scope

  • The Jostoph CLI.
  • A Nix overlay packaging it and placing its binary in PATH.
  • Forgejo configuration declared through the overlay.
  • The systemd webhook listener and service wiring.
  • Ting's rules, compiled into the CLI, with their parameters crystallized in a Nix derivation.
  • The answering surface. Gate-rule execution is the next extension.

Out of scope

  • General-purpose service substrate — that is lar.ad/jostoph-rs.
  • Agentic judgment or an LLM anywhere in the steward loop. The rules are Datalog; they are read, not inferred.
  • Unreviewed Forgejo mutations outside the declared rule/effect boundary.

What it does today

The steward answers. It never acts.

A ruling may declare an effect — forgejo.comment, say — and that declaration is returned and written to the audit trail. Nothing performs it. The service holds no Forgejo token and has nothing to authenticate as; only the separate webhook-reconcile unit does, and all it reconciles is the webhook itself.

Adding an executor is the next extension of this perimeter, and it starts from a record of everything the steward would have done.

Rules

Ting's rules are the ascent! Datalog program in src/policy.rs. Their parameters are nix/policy.nix, crystallized into a JSON derivation at build time and baked into the binary.

Change Where
What the estate requires (sections, protected refs, stewards) 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 build .#policy renders the authoritative one, and nix flake check fails if the two have drifted.

What is currently required:

  • An opened issue or pull request declares In scope, Out of scope, Boundary, and Acceptance. Opened with no body at all is a denial in its own right — there is no perimeter to review.
  • refs/heads/main is not pushed to directly, by anyone. A steward's standing does not extend to bypassing review.
  • Anything an actor the estate does not recognize does is gated.

Standing

Standing is held by groups, not by a flat list of logins, and it flows downward from steward_root:

stewards
└── estate          ← larandar
    └── agents      ← agent.odin

nix/policy.nix declares only the direct nesting. That agents sits under stewards is closed transitively by a BYODS relation:

#[ds(ascent_byods_rels::trrel)]
relation contains(String, String);
contains(parent.clone(), child.clone()) <-- group_nests(parent, child);

No closure rule is written, so none can be forgotten when the hierarchy grows. Standing flows one way: a member of a group that merely contains the root holds nothing, and a group outside the hierarchy confers nothing.

Because a BYODS-backed relation cannot be assigned from Rust, the declared edges arrive in the plain group_nests relation and are copied in by that 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.

Resolution is deny-biased and lives in the substrate. Only the conclusions that carried explain the outcome or carry effects, so a denial still posts the comment that explains it while an overruled allowance does not act.

CLI

jostoph manifest                       # what policy is loaded, and its provenance
jostoph check event.json               # rule on one event; exits 1 on a denial
jostoph check --webhook --kind issues body.json
jostoph serve --listen 127.0.0.1:8420 --webhook-secret-file /run/secrets/hook

--policy FILE swaps the compiled-in parameters, for trying a change before crystallizing it.

serve exposes the substrate's surface — /healthz, /v1/manifest, /v1/evaluate, /v1/audit — plus POST /webhook/forgejo.

Deliveries must carry a valid X-Forgejo-Signature. There is no unsigned mode: an unauthenticated endpoint lets anyone on the network put facts in front of the steward, and every ruling after that is worthless.

Deployment

{
  inputs.jostoph.url = "git+https://jo.et0.pw/Ting/Jostoph.git";

  # in your NixOS configuration:
  imports = [ inputs.jostoph.nixosModules.default ];
  nixpkgs.overlays = [ inputs.jostoph.overlays.default ];

  services.jostoph = {
    enable = true;
    listen = "127.0.0.1:8420";
    webhookSecretFile = config.sops.secrets.jostoph-webhook.path;

    forgejo = {
      url = "https://jostoph.ting.example/webhook/forgejo";
      repositories = [ "Ting/Jostoph" ];
    };
  };
}

The listen default is loopback on purpose: /v1/audit is a record of everything the steward has been told, and that is not something to expose by accident. Put a reverse proxy in front of it deliberately.

Secrets reach both units through systemd's credential store, so they are never in a unit file, the process table, or the environment. The service unit runs DynamicUser with an empty capability set — it reads webhooks, decides, and writes to its log.

The webhook is declared, not provisioned

services.jostoph.forgejo is a declaration. This module does not create, update, or verify the webhook, and holds no Forgejo token — the rendered spec is written to /etc/jostoph/forgejo-webhook.json and that is the end of it.

Reconciling the forge against it belongs to the Forgejo nixops4 provider in lar.ad/flake-ops (#3). Law 12 named a just forgejo recipe; the owner ruled on 2026-08-05 that it should not become a recipe — repo conformance is declared and reconciled like any other resource. There, the hook is one entry on the forge-conformance trait:

webhooks = [
  {
    repository = "Jostoph";
    url = "https://jostoph.ting.example/webhook/forgejo";
    events = [ "issues" "pull_request" "push" ];
    # The same file `services.jostoph.webhookSecretFile` names, so the
    # signer and the verifier cannot drift apart.
    secret_file = "/run/secrets/jostoph-webhook";
  }
];

Keep url and events in step with services.jostoph.forgejo here. They are two declarations of one fact, which is a seam worth naming: this repository renders what it expects to be sent, and the deployment declares what the forge is told to send.

That is also why there is no shell script here. A Forgejo hook carries a forge-assigned id, so its identity is state. A script has nowhere to keep that id and must re-derive it every run by listing hooks and matching on URL — a state lookup standing in for state management. A resource provider keeps the id, which is precisely nixops4's requireState case.

The secret the hook is signed with is likewise nixops4's to mint and rotate (flake-ops#2, persona birth). This repository only names where it lands.

A nix flake check assertion enforces the boundary: if any unit this module generates grows a curl or a FORGEJO_TOKEN, the build fails.

Forge credentials

lar.ad/jostoph-rs is a private repository, so Nix needs a credential to fetch the flake input. There is no flake.lock committed yet for that reason — it cannot be generated without one.

install -Dm600 /dev/null ~/.config/nix/netrc
printf 'machine jo.et0.pw login <user> password <forgejo-token>\n' >> ~/.config/nix/netrc
nix flake lock   # then commit the lock

Nothing else in the build needs a credential: vendor/jostoph is a path dependency populated from the input, so Cargo never talks to the forge.

Development

nix develop          # links vendor/jostoph to the pinned substrate
cargo test
cargo clippy --all-targets -- -D warnings
nix flake check      # build + tests + module eval + policy-drift check

vendor/jostoph is a path dependency populated from the jostoph-rs flake input rather than a Cargo git dependency, so cargo build --locked --offline works inside the Nix sandbox with no forge credentials.

To work against a local substrate checkout:

nix flake check --override-input jostoph-rs path:../jostoph-rs