N0DE

What is proven,
and what is enforced

n0de is attribute-based access control for compartmented workloads. One identity holds every compartment it has been granted, and the decision is taken per access rather than per account. The policy is a machine-checked Lean 4 proof, and every enforcement layer is differentially tested against that proof rather than merely written to agree with it.

This page is the map: what the theorems say, what the gates check, and what is outside both. The last part is the reason to trust the first two.

Written 2026-08-12, revised 2026-10-03

01The problem it retires

In compartmented environments one engineer is routinely cleared into several programs at once. The common workaround is one account per compartment: separate logins, separate desktops, separate credentials, and a human keeping them apart. That arrangement is expensive, it puts the isolation property in the operator's discipline rather than in the system, and it fails at exactly the moment it matters most, which is de-accession. Disabling an account does not touch labelled bytes already on a shared workstation.

n0de takes the other route. Authority derives from current attributes and nothing else, and every access is decided against them. One identity carries ALP and BRA at the same time and reads both. A compartment the identity does not hold is not merely denied, it is invisible: objects in it do not appear, and their existence does not leak through a name collision. De-accession is one attribute change, after which every object carrying that compartment is denied on the next decision, residue on the machine included.

The system runs on ordinary open components. Lean 4.31.0 with mathlib for the proof, Cedar 4.11.2 for the policy decision point, PostgreSQL 18 with row-level security for the store, Node 22 using only its standard library for the identity and file API, and SELinux MCS as the operating-system reference monitor, on Fedora.

02The label, and the one decision function

A label has four axes: level, compartments, need-to-know, and releasability. Levels and compartments map onto SELinux MLS sensitivity and categories. Need-to-know is a first-class dimension rather than a convention. Releasability is the set of countries an object may be released to, with NOFORN being the singleton set containing the United States, and each subject carrying a citizenship. Dominance over that axis is reversed set inclusion, so a narrower release is more restrictive.

Those four axes form a lattice, and dominance over the lattice is the whole of the read rule: a read is permitted when the subject's effective label dominates the object's on all four axes at once. A write must dominate in the other direction, which is the Bell-LaPadula star-property, and it may not widen releasability. Both directions are theorems rather than code review notes: read_permit_sound and write_permit_sound, with transfer_deny recording that the decision point never authorizes a cross-domain transfer at all.

There is one proven decision function, decide, defined in the Lean model. There are then several independent implementations of that policy in the running system: the Cedar policy set, the Postgres row predicates, and the JavaScript decision mirror in the API. They can drift, which is the entire reason the gates exist. Each is differentially checked to agree with decide rather than assumed to encode it, and the same compiled proof function is available as a command-line oracle, so the running engine's verdict can be put beside the proven one on any input.

The distributed agent is a different case and is worth separating, since it is easy to read the gate list as one long equivalence check. The agent's decision path reduces to the same function, but most of the gates around it are operational rather than decision-equivalence tests: they exercise fail-closed behaviour on partition, replication, key handling, revocation, failover and clock trust. Those are properties of the deployment, checked by running it, not statements derived from the model.

03What is proven

The theorems live in model/Pl4Abac/. The keystone is Pl4Abac.noninterference: writes by a high or foreign-compartment subject never change what a low observer sees. Because the label carries all four axes, that statement ranges over all four, including releasability.

theoremwhat it establishesfile
noninterferencehigh and foreign-compartment writes never change a low observer's viewNonInterference.lean
compartment_isolationan identity holding N compartments has zero cross-compartment flowMultiTenancy.lean
read_permit_sounda permitted read implies the object is dominated on level, compartments, need-to-know and releasabilitySoundness.lean
write_permit_sounda permitted write implies the object dominates the subject, with no releasability wideningSoundness.lean
transfer_denythe decision point never authorizes a transferSoundness.lean
naive_create_leaksa shared-namespace create leaks object existence: the threat, stated as a theoremExistence.lean
poly_create_nipolyinstantiation closes that existence channelExistence.lean
deaccess_revokes_readremoving a compartment denies every object carrying itDeaccession.lean
deaccess_residue_invisibleon-disk residue cannot influence a de-accessed subject's viewDeaccession.lean
temporal_deaccess_blindover any trace, a de-accessed subject stays blind to that compartmentTemporalDeaccession.lean
revoked_then_blindblindness persists after revocation until an explicit re-grantReaccession.lean
read_permit_iff / rls_read_refinesthe row-level-security read predicate is decide's read decision, both directionsRefinement.lean
write_permit_iff / rls_write_refinesthe row-level-security insert predicate is decide's write decisionRefinement.lean
low_obs_determinisma subject's view depends only on data it may readCovertChannel.lean
no_uncontrolled_declassificationabsent reviewer authorization, no high writer affects a low observerGuard.lean
only_authorized_flowswith the guard, a run is low-equivalent to running only its authorized releasesIntransitive.lean
lease_expired_denies / lease_expired_blindpast the lease expiry the agent denies every access and sees nothingLease.lean
lease_revoked_blindonce the renewed lease drops a compartment, the agent is blind to every object carrying itLease.lean
naive_compilation_leaksa memoryless decision function provably cannot enforce compilation rules: the aggregation attack, stated as a theoremAggregation.lean
agg_knowledge_boundedwith the knowledge gate, no trace of reads accumulates facets that fire a compilation rule above the subject's labelAggregation.lean
agg_no_tipping_deliverya denied tipping read delivers nothing and absorbs nothingAggregation.lean
marked_deny_leaks / suppress_eq_absenta visible deny marker leaks undelivered facets; the shipped suppression contributes nothing to the subject's view - equal to the object never existingAggregationObs.lean
stale_knowledge_never_overdeniesa lease-carried knowledge snapshot never denies what fresh knowledge permits; staleness exposure is one-directional and TTL-boundedAggregationObs.lean
verdictOk_binds / authorized_within_ceilingthe engine's verdict check as a predicate: a passing cross-domain verdict is bound to its request on every identifying field and carries an output label under the destination ceilingVerdict.lean
verdict_flowscomposed with only_authorized_flows: a low observer's view of a guarded run depends only on the verdict-authorized releases, each ceiling-boundedVerdict.lean
le_jointAuth_iff / coalOk_antia coalition's joint authorization is the meet of its members' labels, so a compilation is authorized iff every member may hold it; sub-coalitions of a safe coalition are safeCoalition.lean
coalRun_invwith the coalition gate, no trace of reads by anyone lets a declared coalition hold knowledge that fires a rule above its joint authorizationCoalitionObs.lean
coalition_reads_interfere / deny_forever / verdicts_independent_of_contenta coalition-mate's read can flip a verdict (the observable, stated as a theorem); per subject and object the verdict is then constant over time, and no verdict depends on any object's contentCoalitionObs.lean

Not the full index, and every name above appears verbatim in docs/THEOREMS.md, which maps each theorem to the attack it stops.

Two results worth reading together

naive_create_leaks and poly_create_ni are a pair, and the first is the unusual one. Rather than assuming the existence channel is a problem, the model proves that a shared namespace leaks the existence of an object a subject may not read, then proves that polyinstantiation closes it. Stating the attack as a theorem is what makes the fix checkable rather than asserted.

The de-accession group is the other one. Three separate statements are needed because a single-step result is not the property anyone actually wants: deaccess_revokes_read covers the next decision, deaccess_residue_invisible covers bytes already on the machine, and temporal_deaccess_blind covers arbitrary traces, so blindness is not a claim about one moment. revoked_then_blind then says it persists until an explicit re-grant, which is what stops a re-accession race from quietly restoring access.

Axiom footprint

No sorry in the model. The proof compiles under the Lean kernel with no admitted goals, and model/check.sh fails if sorry or admit appears. Be precise about that scan's reach: it covers model/Pl4Abac/*.lean, the proof files, and it is not a statement about any other file in the repository. It then prints #print axioms for 25 headline theorems. The footprint is [propext, Quot.sound], with Classical.choice appearing in the ones that reason over finite sets, and never sorryAx. A reader who wants to check that claim runs one script and reads the output rather than taking this paragraph's word for it.

04Proven, then pinned

A proof of a policy says nothing about the code that runs. The bridge is engine/ci.sh, which re-runs every differential gate that pins an enforcement layer to the model and prints one verdict. It builds the Lean oracle and the Cedar policy decision point first, then runs each gate under a 180 second timeout, standing up an ephemeral Postgres where a gate needs one.

There are 42 gate invocations. One is a regression gate over known attacks; the other 41 are the E-numbered gates, which carry labels E1 through E41 because E4 and E5 share a line, E24 is unassigned, and E29 and E34 each run as two lines. The full inventory, read out of the file:

gatewhat it checks
REGRESSION known-attacks deniedthe known-attack corpus stays denied
E1 cedar==lean (5000 cases)Cedar decision point against the Lean decide, 5,000 random cases at seed 42
E2 rls polyinstantiation+diffrow-level security: polyinstantiation, and the predicate differential
E3 file api == leanthe label-filtered file API against the Lean decision
E4/E5 identity read+write+escalationone invocation covering both labels: identity-bound read and write, escalation blocked
E6 lifecycle primitivesagainst an ephemeral Postgres: attribute set, principal listing, audit read
E7 server routes + SPAserver routes and the single-page app, carrying the existence-mask regression
E8 why engine==oraclethe why path: the running engine's verdict against the compiled oracle
E9 access provider (offline)the pluggable access provider, offline
E10 lease fail-closed + bounded-staleness revokereads die at the TTL on partition; revocation bounded by that TTL
E11 live replication authority->agentobjects and keys reaching the agent from the authority
E12 key-in-lease + at-rest encryptioncontent is ciphertext at rest; the key arrives with it and lives in memory
E13 signed immediate revocation (hard-kill)a verified signed revocation latches the agent dark
E14 content-key rotationrotation by epoch invalidates old key material
E15 authority HA: replicas + agent failoverinterchangeable replicas, and an agent that fails over between them
E16 DB HA: pg streaming replication + failoverPostgres streaming replication, and failover to a promoted standby
E17 sync replication: zero-RPO durable de-accessa confirmed de-access survives an instantaneous primary loss
E18 clock trust: monotonic lease expiry + anti-replaywinding the wall clock back, and replaying an older signed token, both rejected
E19 label derivation: JS join == proven deriveLabelthe JavaScript label join against the proven derivation
E20 crypto/lease adversarial (forge/tamper/replay/cross-object)forged, tampered, replayed and cross-object material all fail closed
E21 decision-mirror differential fuzz (8000 cases)the JavaScript decision mirror fuzzed against decide, 8,000 cases
E22 oracle-domain integrity (no silent coercion)out-of-domain input to the oracle is rejected rather than coerced
E23 marking conformance (CAPCO country axis + REL TO grammar)the country axis and the REL TO banner grammar computed from data
E25 aggregation-mirror differential fuzz (9200 verdicts)the knowledge-gate mirror against the proven aggDecide: 6,000 single cases plus 400 stateful traces
E26 compilation-attack corpus denied (agg gate == oracle)the compilation attack in every order, elevation on each axis, deny absorbs nothing, and a cleared subject is not over-denied
E27 aggregation enforced end-to-end (absorb+suppress == oracle)through the HTTP API: suppression generic and persistent, knowledge durable before delivery, verdicts equal to the compiled oracle
E28 aggregation at the edge (K-in-lease, offline gate, report-merge)knowledge rides the signed lease; the offline agent suppresses without marker, absorbs durably, reports upstream, and a wiped machine inherits the suppression from the lease alone
E29 S3 codec round-trip == oracle printLabel / E29 S3 bucket-policy compiler + simulated read == oraclelabels as S3 object keys round-trip through the oracle's printer; the generated bucket policy, simulated, returns the proven read verdict
E30 S3 store adapter under moto (placement, relabel order)the S3 store adapter against a local S3 emulator: object placement by label, relabel ordering
E31 S3 LIVE: real-account GetObject == Lean decide (enforced)a real AWS account: S3 itself returns the proven read verdict under the generated bucket policy. Skipped, not passed, without the account environment
E32 guard: monotone rule == oracle lattice order, routes fail closed, two-person rulethe cross-domain guard's routing rule against the proven lattice order; unknown routes fail closed; two-person release
E33 guard: verdict binding adversarial (forge/substitute/replay/ceiling/hash/state/expiry)forged, substituted, replayed, over-ceiling, hash-mismatched, wrong-state and expired verdicts all refused
E34 guard: cross-domain release end to end (two stacks + mock CDS) / E34 guard: s3 sink LIVE (real bucket, tagged sessions)a release across two running stacks through a mock cross-domain solution; the second line lands it in a real bucket and is skipped, not passed, without the account environment
E35 guard: verifyVerdict == proven verdictOk (5000 cases, real signatures)the engine's verdict check against the Lean predicate verdictOk, 5,000 cases with real signatures
E36 identity: OIDC verifier adversarial (none/HMAC/confusion/forge/iss/aud/exp/kid)the token verifier refuses the algorithm-none, key-confusion, forged, wrong-issuer, wrong-audience, expired and unknown-key cases
E37 identity: claim sync (tombstone blocks re-grant, revoked never cleared)identity-provider claims synced into principals; a tombstoned principal cannot be silently re-granted
E38 identity: LIVE Keycloak (verified identity, attributable officer, sink SA)against a running Keycloak: verified identity, officer actions attributed to a named human, a service account for the sink
E39 guard: LIVE release end to end into an s3-sinked domain (bucket, codec key, receipt)a release landing in a real bucket under the codec key with a signed receipt. Skipped, not passed, without the account environment
E40 guard: receipt-verified ingest (token authenticates, verdict authorizes)the destination authorizes delivery on the guard's signed verdict alone, verified against a key it registered itself; its ingest credential authenticates the caller and authorizes nothing
E41 guard: delivery exactly-once under concurrent retries (delivering claim + reclaim)concurrent officer retries of one release claim delivery through a delivering state and land exactly once

Read directly from engine/ci.sh. Each row names a script in the repository, so the mapping from claim to executable check is one file lookup rather than a matter of trust. Three lines need a real cloud account (E31, the live half of E34, and E39); without it the script reports them as skipped, and it says in its own summary that a skip is not a pass.

Two of these deserve their own sentence. E22 exists because a differential harness can be fooled by its own oracle: the Lean oracle's string parsing silently coerced an out-of-domain field rather than failing, which would have let a differential run compare a verdict on an input neither side actually received. A gate that checks the gate is the unglamorous kind of assurance work that only gets written after somebody looks for it. E21 fuzzes the JavaScript decision mirror against decide over 8,000 cases, because the mirror is the piece most likely to drift and least likely to be noticed when it does.

The kernel gate runs separately, and its scope is narrower

The SELinux differential is not part of ci.sh. It lives in reality/ and is run on its own, because it needs a live kernel with the right policy loaded rather than an ephemeral container. It queries the loaded policy through libselinux for a constrained domain pair, which computes the real kernel access decision including the MCS constraint.

The recorded run is 2026-06-29, on Fedora with the targeted policy, enforcing, MLS enabled: 8 cases, model and kernel agree on all 8. The repository is explicit about what that does not cover, and so is this page. It exercises categories, which are our compartments, and not the sensitivity hierarchy, because the targeted policy fixes sensitivity at s0. It covers the read direction only; the star-property is proven in Lean but was not differentially tested against the kernel. And it is valid only for confined domains, since unconfined_t is exempt from MCS and reads across categories freely, which was observed directly rather than assumed.

05The distributed increment

The single-host arrangement assumes the decision point is reachable. The distributed layer answers the harder question: what does a machine do when it is offline, stolen, or has just been de-accessed and does not know it yet.

An authority mints short-lived signed leases carrying a subject's attributes. A local agent serves reads from its own replica and enforces them against the held lease, contacting the authority only on the renew tick. When it cannot renew, because the network is partitioned or because the authority has revoked the principal, the lease passes its expiry and the agent denies unconditionally. The exposure window is bounded by the lease TTL, and there is no path in the state machine that opens access without a fresh valid lease.

Three theorems in Lease.lean state exactly that. lease_expired_denies is the decision form: once the lease has expired, every access is denied, any object, any action, without consulting the policy at all. lease_expired_blind is the observation form: the agent sees nothing, residue on the box included. lease_revoked_blind is bounded-staleness revocation: once the renewed lease no longer carries a compartment, the agent is blind to every object carrying it, whether or not the lease is currently valid.

The model gates on expiry, not on the signature. The Lean statement is about time: agentDecide consults the policy only while now < exp and denies otherwise, so lease_expired_denies is a real statement about the clock rather than about an opaque validity flag. The other half of lease validity, the signature check, lives in engine/agent/lease.cjs and is exercised by gate E10 and attacked by E20. It is deliberately outside the model, and a reader assessing the distributed layer should treat the crypto as tested rather than proven.

The aggregation gate crosses the same boundary the same way. Knowledge and the compilation rules ride in the signed lease, so the offline agent enforces the gate against the authority's last-known view plus what this machine has itself absorbed - recorded durably before delivery, reported upstream on renew, carried back in the next lease. A stale snapshot is proven never to over-deny (stale_knowledge_never_overdenies); the exposure direction, a tipping fact delivered inside one TTL of a cross-channel absorption, is the same bounded-staleness statement as revocation, and E28 exercises the whole loop including a wiped machine inheriting suppression from the lease alone.

Around that core the gates cover the operational failures rather than the policy ones: content encrypted at rest under per-object keys that the agent holds only in memory (E12), signed revocation as a hard kill that latches until restart (E13), key rotation by epoch (E14), authority replicas with agent failover (E15), Postgres streaming replication with failover (E16), and synchronous replication so that a confirmed de-access survives an instantaneous primary loss with zero RPO (E17). E18 covers clock trust, using a monotonic clock and anti-replay so that moving the wall clock backwards does not resurrect an expired lease.

The cross-domain increment

Release across domains is the one place the model lets information flow down, and it does so only through a guard. only_authorized_flows already said that a guarded run is low-equivalent to running only its authorized releases, with the authorization bit as an input. The increment replaced that input with a predicate. The engine's release check is five steps over an ECDSA-signed verdict tuple, and Verdict.lean states those steps as the predicate verdictOk, with the signature result as the one input that remains: verdictOk_binds says a passing verdict is bound to its request on every identifying field and carries an output label under the destination ceiling, authorized_within_ceiling says a guard cannot lift that ceiling, and verdict_flows composes with the intransitive result so that a low observer's view depends only on verdict-authorized, ceiling-bounded releases. E35 pins the engine's check to the predicate over 5,000 real-signature cases, E33 attacks it, and E32 and E34 run the release path end to end.

The bound is on the authorization ticket, not the delivered bytes. The signature result enters the predicate as a boolean, so the cryptography is tested (E33, E35) rather than proven, the same stance as the lease. The theorems bound the verdict's output label; that the delivered write lands at that label is enforced by the engine's ceiling re-check at delivery and at ingest, and tested (E34), not proven.

Around the guard, three further increments are pinned rather than proven. Identity: a real OpenID Connect provider verifies tokens and syncs claims into principals, so officer actions attribute to a named human (E36 to E38). Ingest: a destination authorizes delivery solely on the guard's signed verdict, verified against a key it registered itself, so the ingest credential authenticates and authorizes nothing (E40), and concurrent retries of one release land exactly once (E41). Storage: labels become S3 object keys and a bucket policy generated from the label universes makes S3 itself return the proven read verdict; the real account is the gate that earns the word enforced (E31), and the two offline lines (E29, E30) check the codec and the adapter.

06What is not claimed

This is the section that makes the rest of the page worth reading. What is proven is the confidentiality decision logic over all four label axes, including releasability, plus its refinement to the row-level-security predicate, plus kernel agreement on the compartment dimension. The disclosed frontiers, stated as the repository states them and not softened here:

One precision on the releasability axis, because it is easy to overstate. The subject side is a citizenship singleton by construction, so every theorem is exercised on that axis at exactly one element. Generalising the subject side to an authorized-disclosure set re-opens the proof obligations on that axis, in particular the write star-property, which must be re-established to still mean "a write may not widen releasability" when the subject's set is a disclosure authority rather than a nationality. The axis is proven, and it is proven over singletons.

Two further limits worth naming in the same breath. The kernel differential covers the compartment dimension, so full sensitivity-hierarchy enforcement end to end is not demonstrated against a live kernel, and closing it needs an MLS-policy host rather than new research. And timing, termination and probabilistic covert channels need a quantitative model, which this is not.

Separately from the proof: an implementation security review answers a different question from the gates, which is whether anything can get around the enforcement rather than whether the enforcement matches the policy. That review found no injection or authentication bypass and hardened three defense-in-depth gaps, and empirical probing found one real existence leak through a duplicate-key status oracle on the write path, since fixed by masking with a regression added under E7. A full external penetration test against a real deployment is still owed and is not substituted for by any of this.

07Check it rather than believe it

Everything above is re-runnable by someone with the repository in front of them. The proof rebuilds and kernel-checks with lake build, and model/check.sh scans for admitted goals and prints the axiom footprint of the headline theorems. The pinning suite is engine/ci.sh, one command, one verdict, ephemeral Postgres per gate as required.

The demo is the same check made interactive: an engineer workspace where one identity spans two compartments and a third stays invisible, a security console that grants and de-accesses live, and a decision probe where a reader types a hostile case and sees the running engine's verdict beside the verdict of the compiled Lean oracle on that same case.

Being exact about what that comparison shows, because it is the point where a demo invites the wrong conclusion. A theorem does not agree or disagree with a case; it is a universally quantified statement, already checked by the Lean kernel, that no input can exercise. What the probe compares is two executions, the engine's and the compiled oracle's, and what it detects is drift between the implementation and the proven function. The theorem is named alongside because it is the property that function was proven to have. Agreement on a case is evidence the implementation tracks the model; the guarantee itself comes from the proof, not from the case.

The releasability case is the one worth trying first: a foreign liaison reading a NOFORN object is denied, and the same read against an object marked REL TO that liaison's country is permitted.

The repository is private today, so this is what an evaluator with access runs rather than something a reader can clone from this page. The demo is one command and about ten seconds to a browser, and it is available on request.

n0de  /  access control, proven, then built  /  mike@0pon.com