- Rust 65.6%
- Nix 34.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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
|
||
| nix | ||
| policy | ||
| src | ||
| .doctor.just | ||
| .fastlane.just | ||
| .fastlane.toml | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.nix | ||
| README.md | ||
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/mainis 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