- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Two defects in one join. Both reachable, both proven by tests that are in this commit and that **failed before the change** — I ran them.
## In scope
**1. An overruled allowance kept its effects.** `carried_effect` matched `claim_effect` against `decisive(source, _reason)` — the source alone. That is correct when sources are gate names, which is what the *steward* level offers. It is wrong for a *deduction*: `DeductionGate` sets `Claim::source` to the program's key — the event id — for every conclusion it derived, so they all share one source and the join cannot tell a denial's effects from those of the allowance it overruled.
The reachable case is not exotic. In Ting's rules a review approval by a login the estate does not recognize both denies (`stranger`) and asks for the merge (`merge_with`). Observed before the fix:
```
verdict: Deny
effects: [Effect { kind: "merge", .. }, Effect { kind: "comment", .. }]
```
The steward refused and declared the merge anyway. Nothing performs effects today, so what it actually cost was an audit record contradicting the decision above it — with an executor it would have been the merge. `claim_effect` now carries the reason and the join uses `(source, reason)`, the same key `Derived::effects` already uses to attach an effect to its finding. The rule the README states now holds for the reason it gives.
**2. Carried effects were not deduplicated.** `carried_effect` is a relation and holds one tuple however many findings asked for the same thing; the `Vec` built from it did not, because the filter tests membership by `(kind, params)` while iterating every claim. A rule set that attaches one comment to each of four findings got four comments, and an executor handed that posts four times.
## Out of scope
- **Resolution's precedence.** Deny-biased, and which claims are decisive, are untouched.
- **The steward level.** Its sources are gate names and were always distinct; the fix changes nothing there, which is why no existing test moved.
- **Effect execution.** Still declared, never performed.
## Boundary
`src/resolve.rs` only: the `Resolution` program's `claim_effect` arity and `carried_effect` join, the seeding that fills them, and the dedup on the way out. Two tests added. No public type changes — `Claim`, `Effect` and `Resolved` are as they were.
## Acceptance
- `one_source_that_both_allows_and_denies_drops_the_overruled_effects` — fails before, passes after.
- `one_effect_asked_for_twice_is_carried_once` — fails before, passes after.
- Every existing test still passes, unmodified.
Verified locally, not argued: **21 lib + 8 integration + 3 doc tests pass, and `cargo clippy --all-targets -- -D warnings` is clean.** The two new tests were written first and observed failing (`left: 2, right: 1` on both).
## Note for the reviewer
`Ting/Jostoph`'s standing-comment change depends on defect 2 being fixed here: one comment asked for by four findings is one comment only once the carried set stays a set. Against the currently pinned substrate its new test fails, correctly. So this wants to land, mirror, and be picked up by `nix flake update jostoph-rs` before that one.
Authored as `agent.odin`.
Co-authored-by: larandar <[email protected]>
Reviewed-on: lar.ad/jostoph-rs#5
|
||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
jostoph-rs
Reusable Rust substrate for auditable Tokio services and gate-driven stewards.
A steward holds a set of gates. Each gate inspects an Event and returns
a Ruling; the steward resolves those rulings into one Decision, writes it to
an AuditSink, and answers.
Perimeter
This crate is substrate. It contains no policy, no credentials, no effectors,
and no knowledge of any particular forge — Event::source and Event::kind are
opaque strings and payload is arbitrary JSON. Downstream projects own policy,
deployment, and the execution of effects.
A ruling may declare an effect (Effect { kind, params }). The crate never
performs one. Declaring and effecting are separate so that every intended
mutation is recorded and reviewable before anything in the world changes — the
foundation the gate-rule execution layer is meant to sit on later.
Answering surface
jostoph::service::router(steward) yields an axum::Router with:
| Route | Meaning |
|---|---|
GET /healthz |
Liveness. No policy involved. |
GET /v1/manifest |
Service name, version, and every loaded gate's declaration. |
POST /v1/evaluate |
Rule on one Event; returns a Decision. Declares effects, performs none. |
GET /v1/audit?limit=N |
Recent decisions, newest first. |
It is a plain Router rather than a sealed server so a downstream binary can
.merge() its own routes — a webhook receiver, say — onto the same listener.
Reachability is the trust boundary
No route authenticates. That is deliberate — this crate holds no credentials and knows no identity system — but it makes who can reach the port the whole of the access control, and two consequences follow that are easy to meet late:
POST /v1/evaluateaccepts anEventfrom anyone who can reach it, andAuditSink::recordruns inline on that path. So an unauthenticated caller does not merely read the trail; they append to it, and with the default boundedMemorySinkeach append evicts the oldest record. A steward whose port is reachable can be made to forget..merge()puts the downstream routes on this same listener. A webhook receiver that verifies signatures does not protect its neighbours: anything put in front of the hook — a reverse proxy, a public name — fronts/v1/auditand/v1/evaluatetoo unless it filters by path.
The cheap answer to both is the one the surface already suggests: bind loopback, and co-locate with whatever delivers to it. Anything else means authenticating in front of the router, which is the downstream's to do.
Dispatch and resolution
The two decisions the substrate itself makes are written the same way gates are
— as ascent! programs, in jostoph::resolve. Rust only marshals tuples.
Dispatch derives which gates an event concerns from their declarations. A
gate that declares no sources (or no kinds) is unconstrained on that axis,
expressed as the absence of a constrains_* tuple rather than an emptiness
check, so "matches anything" and "matches these" are the same rule shape.
Resolution is deny-biased:
- any
Deny, or any source that failed, denies; - otherwise any
Allowallows; - otherwise
Abstain(including when no gate covered the event).
A gate that fails is not evidence of consent. Only the claims that carried explain the outcome or carry effects: a refusal may still act — posting the comment that explains it — while an allowance that was overruled takes its effects with it.
Resolution runs at two levels with the same rules: a deduction folds its own
conclusions into one ruling, and the steward folds every gate's ruling into one
decision. The precedence is stated once.
Deduction
Rules are meant to be written as Datalog, over
ascent. jostoph::deduce lowers an event
into flat relations a program can join over:
| Relation | Tuples |
|---|---|
event(id, source, kind) |
exactly one |
subject(id, subject) |
zero or one |
field(id, pointer, atom) |
one per scalar leaf, keyed by RFC 6901 pointer |
nests(id, parent, child) |
one per direct parent/child edge of the payload tree |
Containers produce no field tuple of their own — only leaves carry values —
but they do appear in nests, which is what makes the tree navigable. Atom
keeps JSON numbers as their source text: relations must be Eq + Hash, and
floats are neither.
Navigating the tree with BYODS
nests holds only direct edges. Reachability is the caller's to close, and
closing it is one line with a BYODS
transitive relation:
relation nests(String, String, String); // seeded from Facts::nests
#[ds(ascent_byods_rels::trrel)]
relation under(String, String, String);
under(id.clone(), p.clone(), c.clone()) <-- nests(id, p, c);
// Every field anywhere beneath /issue, at any depth.
beneath_issue(id.clone(), ptr.clone()) <--
under(id, "/issue".to_string(), ptr), field(id, ptr, _);
The ternary form closes per id, so one program may hold several events without
their trees joining.
Two properties of BYODS-backed relations are worth knowing before you reach for
one: they cannot be assigned from Rust (seed a plain relation and copy it in
with a rule, as above) and cannot be read back as a Vec (derive into a
plain relation to observe them).
Implement Deduction, seed the relations, run to fixpoint, and hand back the
output relations as Derived — verbatim, with no folding or precedence of your
own. into_gate() resolves them into a Ruling:
use ascent::ascent;
use jostoph::deduce::{Atom, Deduction, Derived, Facts};
use jostoph::prelude::*;
ascent! {
struct Policy;
// Seeded from the lowered event.
relation event(String, String, String);
relation field(String, String, Atom);
// Seeded by the caller — the knob a deployment gets to turn.
relation required_section(String);
relation mentions(String, String);
mentions(id.clone(), needle.clone()) <--
field(id, _pointer, ?Atom::Text(text)),
required_section(needle) if text.to_lowercase().contains(needle.as_str());
// The output relation, shaped for `Derived::conclusions`.
relation conclusion(String, Verdict, String);
conclusion(id.clone(), Verdict::Deny, format!("no `{needle}` section")) <--
event(id, _source, kind) if kind == "issues.opened",
required_section(needle),
!mentions(id, needle);
}
struct Perimeter;
impl Deduction for Perimeter {
fn declaration(&self) -> GateDecl {
GateDecl::new("perimeter", "issues declare a perimeter").on_kind("issues.opened")
}
fn deduce(&self, facts: &Facts) -> Derived {
let mut program = Policy::default();
program.event = facts.events().to_vec();
program.field = facts.fields().to_vec();
program.required_section = vec![("in scope".to_string(),)];
program.run();
Derived { conclusions: program.conclusion, ..Default::default() }
}
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let steward = Steward::builder("my-steward")
.version(env!("CARGO_PKG_VERSION"))
.gate(Perimeter.into_gate())
.audit(TeeSink(TracingSink, MemorySink::new(512)))
.build();
jostoph::service::serve(steward, "127.0.0.1:8080".parse().unwrap()).await
}
Depend on ascent directly — the macro expands to absolute ::ascent::… paths,
which no re-export from here can satisfy. Keep it at jostoph::deduce::ASCENT_REQ
so the tuple types line up.
Splitting rule logic (compiled ascent!) from rule parameters (seeded
relations like required_section) is what lets a deployment crystallize its
knobs — from Nix, say — without owning the deduction.
Derived::effects carries (key, reason, effect kind, params as JSON text),
joined to a conclusion by (key, reason) so an effect attaches to the specific
finding that asked for it. Params travel as text because relation columns must
be Eq + Hash and serde_json::Value is neither.
Gate remains the underlying interface, so a rule that genuinely needs
imperative Rust can implement it directly. Gates run sequentially in
declaration order: a serial audit trail is far easier to read back than an
interleaved one, and deduce is expected to be cheap, local, and non-blocking.
Conclusions are sorted before resolution, so two runs over the same event
produce byte-identical audit records.
Audit sinks
MemorySink (a bounded ring, the default) makes a freshly started steward able
to answer for itself with no external storage. TracingSink emits each decision
as a structured log event. TeeSink combines them. Durable storage is a
downstream concern; implement AuditSink for it.
Development
cargo test
cargo clippy --all-targets -- -D warnings
Downstream: Ting/Jostoph — the estate-specific projection that supplies Forgejo integration, Ting policy, and deployment.