The executable model
The executable model is a Rust crate, redoubt-model in model/, that states the kernel’s
objects, calls, errors, rules and invariants as code, with the steward’s policy core on top. Seeded
random sequences of calls drive it, and after every step a checker recomputes each invariant
from the objects themselves. Deliberate breaks of each rule, the mutations, show that the
checks catch what they claim to catch. Every run can be written as a text trace and
replayed; replaying those traces on the real kernel compares the kernel with the model.
Purpose
A rule in prose can be read two ways; a rule in code cannot. The model is the design in a form a machine checks: if any sequence of calls it generates reaches a state that breaks an invariant, it reports the seed and a shrunk trace that shows how. It is the reference the kernel is built to: the same call names, the same errors, the same constants and the same order of argument checks (ABI). A design change is tried in the model first, because a change that breaks a property fails there in seconds, on a laptop, with the failing sequence in hand.
What the model is
Status: built · partly tested: independence from the kernel’s source and the empty dependency list are held by model/Cargo.toml and #![forbid(unsafe_code)], not attacked by a case · tested: host:redoubt-model::overflow_checks_are_on, host:redoubt-model::map_fixed_near_user_top_does_not_starve_map_anon, host:redoubt-model::event_free_tick_matches_slice_reference
- Independent. The crate is
no_stdwithalloc,#![forbid(unsafe_code)], and its[dependencies]table is empty: it links no kernel crate, notredoubt-sys, nothing from crates.io. It states its constants itself, with the kernel’s names and values:WORDS(4),MAX_MSG_HANDLES(4),MAX_HANDLES(4096),MAX_LEND_PAGES(16),MAX_THREADS(255),WAIT_CAP(32),MAX_OPEN_CALLS(256),SLICE(1,000 µs),STRIDE(2^20). Two crates use the model, each as a dev-dependency:redoubt-stride, for its differential test (below), and the test bench, whose scheduling oracle must reject traces from the model’s scheduler with its tie rules broken. - One method per call.
Kernelinmodel/src/kernel.rshas one method for each call, with the call’s name. Every argument arrives as a rawu64, as from user mode, and the checks run in the kernel’s order: decoding asredoubt-sysdecodes, then each argument from left to right, then permission, then resources. - Overflow is a failure. The root
Cargo.tomlkeeps overflow checks on for this crate in both dev and release builds, so an arithmetic overflow is a panic, and the runner counts any panic as a failure of I14 (no call panics the kernel).overflow_checks_are_onfails if a build turns them off.
| File | What it holds |
|---|---|
spec.rs | constants, errors and the small value types of the calls |
syscall.rs | calls as data, their results, and the other events: user loads, stores and fetches, faults, interrupts, time passing, a thread’s record changing validity |
kernel.rs | the kernel’s objects and one method per call |
sched.rs | the stride scheduler |
ghost.rs | ghost state: facts the checks need that the kernel does not keep |
invariants.rs | the checker run after every step |
check.rs | the property families, the trace epilogue, shrinking |
gen.rs | the seeded generator of operation sequences |
mutation.rs | the deliberate breaks |
trace.rs | the trace format: writing, reading, replaying on the model |
steward.rs, policy.rs | the embedder of the steward’s policy core, and its properties |
serving.rs | a server’s admission share (R26 (admission fairness)) |
What it abstracts
- Memory is page frames with one word of content each. Loads, stores and fetches check the
mapping’s permissions. A freed frame is in flight, still holding its word, until a
tick, when the one hart the model runs zeroes it and frees it, as several harts do between sections (R81 (frames in flight)); a frame never used since boot holds a word of its own until its first allocation zeroes it. A process’s own mapping, a server’s view of a lend and the lender’s reservation are separate states. Page tables follow Sv39’s layout as a placement rule, even when the cost table is rv32’s. - Threads have no registers. A call is instantaneous and atomic; time is logical and
advances only on a
tick. So the model tests accounting and state changes, not real-time latency, and not races between harts. A tick charges time slice by slice, but where nothing is due, to deliver or to wake, its whole slices are charged at once: with one budget queued, or with several (the same picks, charges and requeues, on the queued budgets alone).event_free_tick_matches_slice_referenceholds every such tick equal to the slice-by-slice reference, field by field, on boundary worlds of one to five queued budgets and on random histories, with no mutation and under every one. - Records (the user memory a call reads and writes) are abstracted as a whole: owned, unmapped, read-only, borrowed, device memory, a copy that faults, or an address whose every page, for the call’s record size, is checked against the modelled mappings. Byte layouts are not modelled.
- PIDs are drawn by a seeded generator from 2 to
0xffff;initis 1. This tests reuse and accounting, not unpredictability. - Addresses the kernel chooses are the kernel’s: a
map_anon,map_deviceordma_allocrun is placed in the 256 MiB area fromDEFAULT_BASE, and a receiver’s lend or transfer in the 4 MiB area fromDEFAULT_MESSAGE_BASE, each searched as the kernel searches it (memory). A full area is refused as the kernel refuses it. - Costs come from a
coststable: by default the kernel’s table (one header page per process, one page for each other object, 64 handles per handle-table page). The property tests use 8 handles per page, so handle-table growth and its charge show up in short runs. - Boot.
root,systemandusersget the kernel’s weight split:root1,000,000,system250,000,users749,000, sorootkeeps 1,000 free forinit. Page and process limits are small fixed numbers (root: 1,024 pages, 24 processes), not derived from RAM. The default boot has seven device objects: an MMIO range without DMA, an MMIO range with DMA, IRQ lines 10 and 11, the reset device, a DMA device whose first reset is not confirmed, and a second DMA device whose resets always confirm. - Where the design leaves a detail open, the model makes a choice and the code says so at the site.
Property families
Status: built · tested (7)
- host:redoubt-model::kernel_sequences
- host:redoubt-model::budget_lifecycles
- host:redoubt-model::scheduler_fairness
- host:redoubt-model::flood
- host:redoubt-model::every_call_and_error_is_reached
- host:redoubt-model::reset_at_one_death_does_not_cover_a_co_holder
- host:redoubt-model::the_reach_table_is_reproduced
A family is a function of one seed and an optional mutation. The runner
(model/tests/common/mod.rs) runs seeds on several threads, keeps the lowest failing seed, and
counts a panic as an I14 failure. A failing kernel sequence is shrunk to the steps that matter
and printed as a trace. Rerunning the seed reproduces the failure exactly.
The generator (model/src/gen.rs) first builds a world worth attacking: principals’ budgets
under users, some labelled and with accounts, a system budget, shared endpoints, handles
minted into the principals’ budgets, and a process in each budget. One sequence in five skips
this setup and starts from init alone. Then it picks operations
from the model’s state: a handle the actor holds, a page it has mapped, a message it serves.
About one argument in ten is hostile instead (a handle index the actor lacks, an unaligned
address, a list over its cap). every_call_and_error_is_reached checks over 5,000 seeds that
every call succeeds at least once, every error is returned by some call, and lends, transfers,
each exit cause, interrupts and replies all happen, so no property holds only because nothing
reached it.
| Test | Default sequences | What must hold |
|---|---|---|
kernel_sequences | 20,000, up to 150 steps each | every check of the checker, after every step |
budget_lifecycles | 20,000 | I10 (create-destroy leaves the parent unchanged): a child created, used only from inside its subtree and destroyed leaves every other budget’s counters as they were |
scheduler_fairness | 20,000 | R12 (scheduling), as the scenarios below |
steward_policy, steward_noninterference | 2,000 and 3,000 | the steward model |
flood | 20 scenarios of up to 10,000 senders | R2 (fair waiting), I11 (fair turns) and R4a (open calls) under a flood |
A default run is 65,020 sequences. The bench case model-host-tests runs it but for the steward
families, in under two minutes, the build included; steward-model-host-tests runs those
two, each a job of its own on four threads (fanout), with the
check below, in under two minutes, the build included.
REDOUBT_MODEL_SEQUENCES sets the count per family. A bound on cargo’s test threads is a bound
on the whole binary: each test’s runner spawns MODEL_THREADS threads if it is set (a positive
integer; anything else fails the test, naming it), else one if RUST_TEST_THREADS is set, else
one per core. The test million runs 1,000,000 sequences in each of the five non-flood families
and 1,000 floods; it is marked ignored and runs only when asked for.
A steward operation, the core’s decision, the embedder’s calls on the kernel model and every check after it, costs tens of microseconds. Most of a steward seed is its ticks of minutes over several queued budgets, which cost a thousand slices a second of model time slice by slice and are charged in batches (what it abstracts). Measured on one thread, in release:
| Family | A seed, slice by slice | A seed, batched | Count | One core |
|---|---|---|---|---|
steward_policy | 0.43 s | 0.011 s | 2,000 | under half a minute |
steward_noninterference | 0.73 s | 0.025 s | 3,000 | about a minute and a quarter |
The counts come from what the seeds reach, not from habit. model/tests/steward_reach.rs
records, per seed, each rule of the core’s Policy table that held and each that refused, each
effect that ran, each filter’s answers, and each property’s first instance (a session’s budget
checked, a declassification copied out, a blame, a lockout, a lease ended, an observation after
vault work left out). Searched to 20,000 seeds, steward_policy reaches 148 of these, its
last new one at seed 439, and to 10,000 seeds steward_noninterference 151, its last at seed
296. A family’s count comes from that measured seed of its last new coverage: four times it,
rounded up to a thousand, but never below a seed at which the family’s random search catches a
mutation (PolicyDeclassifyUnfit at 1,418, and PolicyDeclassifyLive at 1,166), so 2,000 for
each; steward_noninterference runs 3,000, a margin it keeps. The counts are for coverage:
the three mutations whose state the random search builds only by chance are held by their named
scenarios (mutations), whatever the counts.
the_reach_table_is_reproduced checks what the first seeds reach: seeds 0 to 439 of
steward_policy reach 148 items and 0 to 296 of steward_noninterference 151, the last of
them first at that seed, so a change to the generator, the core or the checks that moves either
fails. That no later seed reaches a new item, and the catch floor of 1,418 and 1,166, come from
the ignored runs (reach_table, catch_table in model/tests/steward_reach.rs): they are
recorded here and checked by no test. Four of the core’s effects, and the refusal of its rule
is_context, are reached by neither family (residual risks).
The checker
After every step, Checker::check (model/src/invariants.rs) runs its checks in a fixed order:
- the objects’ structure; the console’s holder is a live process (the console’s one writer); serving state;
- handles: I1 (handles name live objects), I2 (revocation is complete), I3 (minted badges are non-zero and narrow) and I4 (only badge-0 handles receive);
- charging: I5 (usage within limits), and R6 (charging)’s backing:
root’s limit and its own page fit in the boot’sram_frames; - budgets: I6 (labels only grow downward) and I8 (class and account inherited);
- the step’s flows and call outcomes;
- memory: I9 (pages W^X, zeroed, lends unmapped) and I16 (DMA pages reset before reuse);
- queued messages, then abandoned-call notices: I15 (abandoned calls reported once);
- delivery, exit notices owed, and timeouts: I13 (every blocking call returns by its timeout).
I12 (ids never reused) is checked from ghost records as ids are issued.
Each check recomputes what should be true from the primary objects (handle tables, budgets,
frames, each thread’s wait state) and from ghost state, never from the counters and derived
fields the kernel model keeps. Ghost state (model/src/ghost.rs) is recorded from the primary
objects at the moment of each event, and nothing in the kernel model reads it. So a bug, or a
mutation, cannot hide itself by also changing what the check believes.
The flow checks judge each step against the ghost records:
- a delivered message is the one sent: its kind, badge, account, labels and handles are what the sender’s handles and budget gave, a handle revoked meanwhile arriving as 0 and every other keeping its stamp;
- it reached a thread receiving through a badge-0 handle to its endpoint, and between user budgets only equal label sets flow, the receiver being the endpoint’s owner (R1 (flow), I7 (every flow obeys R1));
LabelDeniedandBusyhappen only when R1 and R2 say so;- a caller gets a reply only when its server replied, and an abandoned-call notice goes to the thread holding the call, once;
- every completed
callreports the lend disposition its history implies, and apresentreply only with success orOutOfMemory.
For I16, the ghost arms each DMA frame against every device its holder could reach, and
disarms a device only on a reset that the device object confirms (not on what the kernel
model reports). No frame in the free pool may be armed. A confirmed reset at one process’s death
disarms only that process’s frames: a live co-holder that still reaches the device can program
it again, so its frames stay armed until its own death resets the device. A quarantined frame
must be mapped nowhere, and a quarantined device used again is a violation. The default boot’s
second healthy DMA device exists so the generator can build the co-holder shape;
reset_at_one_death_does_not_cover_a_co_holder builds it by hand.
Scheduler scenarios
scheduler_fairness draws one scenario per seed and drives the scheduler alone through time:
| Scenario | Property |
|---|---|
| classic | spinners and sleepers: a spinner gets its weight’s share over every interval it was runnable |
| gaming | short bursts, below a slice or at a large weight, buy no more than the weight |
| idle gap | a sleeper waking into an empty queue banks no credit |
| exit churn | exiting, faulting or being killed on the CPU is charged, and a timer’s work for the exiter’s timeouts is its |
| budget churn | creating, running and destroying children (blocking, with a spinning parent, by deadline, parked) gains nothing |
| carve inflation | carving moves share to the child and never duplicates it |
| debt lift | a light grandchild’s work reaches a shared parent, normalized by weight |
| idempotence | creating and destroying a child with no run leaves the parent’s pass unchanged and its remainder unchanged or smaller by rounding (exactly 0 if it was 0), because the carve rescales the remainder; a carve under a running parent first charges the pending runtime at the weight it ran at |
| rank | every pick among equal passes follows the rank rules, checked by an independent oracle; a wake never preempts |
| shell | a parent that gives each short command a heavy child keeps its share |
| late join | at two harts, A (900, one thread) and B (100, one) run long, then C (100, one) wakes: from a slice after, A gets a hart and B and C half each; the floor that counts a capped budget gives C B’s hart (R12CappedHoldsFloor) |
| second cap | at three harts, A (1000, one), B (100, one), C and D (10, five each) run long, then E (10, one) wakes: A and B a hart each, C, D and E a third each; a cap test that stops at the first capped budget gives E more (R12CapOnce) |
| uncap | at two harts, A (900, one), B and C (100, one each) run long, then A gains a second thread: A 1.64 harts, B and C 0.18 each; an uncapped budget not lifted to the floor takes both harts (R12UncapBanksCredit), and so does one whose second runner is not charged (R12SpreadChargesOnce) |
| carve waiter | at two harts, on half-slice steps with the harts’ slices apart, A (100, one) beside B (100, two), which carves half its weight to an empty child before each step and takes it back after: a hart each; a cap that takes A while its thread waits for a hart, and a lift to the floor when the carve returns, give B A’s waits (R12UncapForfeitsWait: A 666) |
| heavy capped | at two harts, on half-slice steps with the harts’ slices apart, A (900, one) beside B and C (100, one each): A a hart, B and C half each; A is capped and its own slice ends, inside B’s and C’s, gain it nothing; a pick that ignores weight takes A’s hart from it (R12IgnoreWeight) |
| spread | A (100, four threads) and B (100, one): at two harts a hart each, at four A three and B one; a budget limited to one hart at a time gets one (R12OneRunnerPerBudget) |
| idle harts | at two harts, A (100, one) runs alone, then B (100, one) beside it, then C (100, one) wakes: two thirds of a hart each; a floor that holds while every queued budget is capped lets B enter at a stale pass and starve A (R12AllCappedHoldsFloor) |
The shares in the last seven are water-filling’s, computed by the scenario from its weights and
threads, in thousandths of a hart over 400 slices from one slice after the event, within 50 per
thousand of the machine. The scheduler runs any number of harts (Scheduler::set_harts); each
pick is one hart’s, of the lowest-ranked budget with a runnable thread no hart runs, and the cap
set is found its own way, by water-filling to a fixed point over every queued budget.
The rules themselves are on scheduling.
The flood
flood is the endpoint-flooding attack, in the model. A system server receives on one endpoint
with two threads. Bob’s processes, with two label sets under one account (so two R2 groups),
run up to a fixed 31 threads each (the attack’s shape, not MAX_THREADS), all calling with no
timeout. Enough other accounts that more than MAX_OPEN_CALLS calls can queue (seven to twelve)
queue WAIT_CAP calls each, and the server’s budget has a page for each open call. Alice makes
one call in the middle of the flood. Most of Bob’s calls must get Busy, no crowd process may
get Busy within its own group’s cap, and Alice’s call must be taken within as many receives as
there are groups. In half the seeds the server hoards: it receives without replying until the
process holds MAX_OPEN_CALLS, spread over both threads, and it must still take a send. The
checker runs every 512 steps and at the end.
Scripted contracts
Status: built · tested (8)
- host:redoubt-model::ipc_completion_table_and_rollback
- host:redoubt-model::sparse_committed_reply_mask_is_positional
- host:redoubt-model::scheduler_contracts_hold
- host:redoubt-model::pid_reuse_only_after_notice_receipt
- host:redoubt-model::deaf_device_quarantines_the_co_holder_too
- host:redoubt-model::partial_overlap_is_refused_whole
- host:redoubt-model::a_destruction_delivers_at_its_end
- host:redoubt-model::a_siblings_destroy_spares_its_neighbours
Some cases are rare in random runs, so fixed sequences pin them. The IPC contracts are written from the completion table, not from the model’s output. They cover:
- every row of the completion table and the rollback of a reply that cannot be written, for
R13 (one outcome per call); reply masks that are positional, not a count
(
model/tests/current_contracts.rs); - a timeout wakes without preempting, a timeout and a budget deadline at one instant expire in
that order, budgets with no free weight are refused a process, and the pick and switch into a
budget are billed to the budget picked, not to the one whose thread blocked before it; at two
harts, a budget whose last thread ends on its hart raises the floor to no pass of its own
(
R12EmptyHoldsFloor), and a heavy budget whose slice ends before any other floor raise saw it run is capped by the raise its own slice end makes (R12RequeueWaitsForCap), and one whose running thread blocks while its sibling waits is not, so the sibling holds the floor (R12BlockLeavesAsRequeued); - a PID is reused only after its exit notice is received or dropped;
- a destruction delivers nothing until its end: a notice owed during it goes to the receiver that
survives, not to one the destruction ends later, and a send stamped with the dying budget fails
with
Dead; - a destruction among live siblings ends exactly its own subtree: sixteen children of one parent
with a process each, every one reporting to one endpoint, end as a sponsor’s leases do (the
process exits 0, the notice is taken, the budget destroyed), in five orders, and after every end
each other child still holds its budget, which counts its one process, and its process
(
model/tests/destroy_contracts.rs); - DMA frames stay held through
unmap, return to the pool only after a confirmed reset, and are quarantined, with the co-holder’s, when a reset is not confirmed (model/tests/dma_contracts.rs); map_fixed’s ranges, flags, overlap and cost checks (model/tests/map_fixed_contracts.rs).
The mutation check runs the IPC and scheduling contracts first, for every variant, and a steward scenario for each of the three breaks the steward families catch only after many seeds (mutations).
Mutations
Status: built · tested: host:redoubt-model::every_rule_has_a_mutation, host:redoubt-model::mutations_are_caught, host:redoubt-model::declassify_unfit_scenario, host:redoubt-model::one_cursor_scenario, host:redoubt-model::agent_other_set_scenario, host:redoubt-model::end_lease_admitted_scenario
A mutation is one deliberate break planted in the model. Each variant of enum Mutation
(model/src/mutation.rs) breaks one rule, at the sites in the model marked
self.broken(Mutation::...): one site for most variants, two or three where the rule is kept in
more than one place, and a direct comparison with the mutation for AbandonNoticeMissing and
R11LendStaysMapped. With no mutation, the model is the specified kernel.
Mutation::ALL lists all 176 variants. Mutation::rule() returns the ID each one breaks, as in
the table below; the steward’s variants, named Policy..., break the server rules the steward
model checks. Each of those but four is one broken entry of the core’s Policy table
(mutation::policy), since the crate that ships has no mutation switch; the other four break the
model’s embedder: its entropy, its admission, a volume’s write check and the server’s start.
every_rule_has_a_mutationrequires at least one variant for every kernel rule the model holds (each R row of the table below, before the steward’s) and for I16.mutations_are_caughtplants each variant in turn. It runs the scripted IPC and scheduling contracts, the variant’s steward scenario if it has one (below), then every property family, the one that pressures the rule first (scheduler_fairnessfor R12, the steward families for thePolicyvariants,floodfor the open-call limit,steward_noninterferencefor the breaks that show as one domain’s work in another’s view,R2OneCursor’s turns among them), up to 20,000 seeds each (20 for the flood) but 500 in each steward family. A variant not caught within those caps fails it, by name: one the steward families catch only past seed 500 is caught too late, and so is a failure, not a wait. It prints the property and seed that caught each one.REDOUBT_MODEL_MUTATIONSnarrows the run to the variants whose names contain one of its comma-separated words, or to the one a word names whole. The bench casemodel-mutationsruns it once per variant, each a job of its own in release, the names coming fromcargo run --example mutations(fanout);model-host-testsskips it. Every variant is caught within the caps, each job in under 20 seconds on one core.- Four variants whose state the steward families’ random search builds only by chance, or too
slowly for the bench’s deadline, have a
directed scenario each (
model/tests/common/contracts.rs), a few operations that build the state the break needs, tried before any family. Each is a named test that holds on the specified model and catches its variant with the property it names, so these catches are deterministic and the random search is the backstop:declassify_unfit_scenario: a {7} session writes an item one byte overDECLASSIFY_MAX, or one with a control character, asks to declassify it and its owner approves; without the item’s check, P6 sees it copied out. The random search:steward_policy’s seed 1,418.one_cursor_scenario: alice’s and bob’s unlabelled sessions and bob’s {9} session call the server, which takes alice’s call, then the {9} call, then bob’s and alice’s next; with one cursor over every group the last round starts after the {9} group with the vault’s work and after alice’s without, so P10 sees the unlabelled calls taken in another order. The random search:steward_noninterference’s seed 944.agent_other_set_scenario: a {7} session asks for an unlabelled agent and its owner approves; without the own-set check, P10 sees the request in the unlabelled audit view. The random search:steward_noninterference’s seed 48.end_lease_admitted_scenario: alice’s session starts an agent, the two fill her domain’s pending requests, and the session ends the agent; with the embedder ignoring the tables’ ahead-of-admission mark, the full domain refuses it and P13 sees a sponsor that could not end its agent. The random search:steward_policy’s seed 36 andsteward_noninterference’s seed 126.
| ID | Variants | What they break |
|---|---|---|
| R1 (flow) | R1SkipLabelCheck, R1ExitNoticeIgnoresLabels, R1UsageIgnoresLabels, R1UsageExemptBySystemTarget, R1ExitExemptBySystemExiting, R1ChecksReceiverNotOwner, R1SenderClassFromStamp | the label check on messages, exit notices and usage reads, and which side’s class exempts it |
| R2 (fair waiting) | R2FifoAcrossAccounts, R2NoWaitCap, R2KeyByAccountOnly, R2KeyByStampLabels, R2SystemCallersShareGroup, R2OneCursor | turns, the cap, how groups are keyed, and one label set’s turns apart from another’s |
| R3 (lends and abandoned calls) | R3UnmapAbandonedLend, R3ChargeStaysWithCaller, AbandonNoticeMissing, AbandonNoticeRepeated, BadRecordConsumesNotice, EndpointDestroyNoticeKept | an abandoned lend’s mapping and charge; the notice, once, kept for a good record, and none on a destroyed endpoint |
| R4 (delivery) | R4IgnoreMaxTransfer, R4OverdrawOnDelivery | max_transfer; paying for a delivery |
| R4a (open calls) | R4aOpenCallsPerThread, R4aFullTakesNothing, OpenCallsUnlimited, ReceiveDropsOpenCalls | the limit, per process; sends and notices at the limit; keeping open calls across a receive |
| R4b (a server dies) | R4bDeadServerFakesReply | Dead for a dead server’s callers |
| R5 (interrupts) | R5NoMaskOnFire, R5NoUnmaskOnReceive, R5BadRecordConsumesInterrupt | masking on fire, unmasking on receive; an interrupt kept for a good record |
| R6 (charging) | R6ChargeAncestors, R6OwnPageChargedToItself, R6EndpointsFree, R6PageTablesFree, R6EmptyTableKept, R6OpenCallsFree, R6ProcessObjectFree, R6ProcessObjectChargedToBudget, R6LendChargedOnce, R6RootPageUncounted, R6PidUncountedAtEnd | who pays for each object, and for how long a page table; root’s own page; how long a PID counts |
| R7 (carving) | R7NoCarveCheck, R7CarveToZeroFree, ProcessInWeightlessBudget | carving within free limits; no process in a budget with free weight 0 |
| R8 (accounts) | R8AccountFromArgument | inheriting the parent’s account |
| R9 (stamps) | R9ReceivedHandleRestamped, R9MintStampsCaller, R9MsgStampIsSenderBudget | which budget a handle is stamped with |
| R10 (destruction) | R10KeepForeignHandles, R10KeepCarvedLimits, R10SpareDescendantProcesses, R10ExitNoticesOutlivePayer, R10RevokedMessageDelivered, R10RevokedCallAnswered, R10SweptHandlesDropped, R10CreatorDeathSparesProcess, R10HeldPidsDropped, R10ReapDestroysParent, R10ReapKeepsCarve, R10ReapSkipsGrandchildren, R10DeliveredMidDestruction, BudgetDeadlineIgnored | everything a destruction reaches, nothing delivered before its end, a deadline destroying the budget, and a reap destroying one child and keeping the budget |
| R11 (memory) | R11NoZeroing, R11SetFlagsAllowsWx, R11SetFlagsAllowsWriteOnly, R11LendStaysMapped, R11MapFixedSkipsOverlap, R11ExecOnDeviceMemory, R11ProcessMapSkipsFlags | zeroing a frame free since boot, W^X per mapping and per frame, write without read, lends unmapped, map_fixed never replacing, process_map’s own flag check |
| R81 (frames in flight) | R81InFlightAllocatable, R81CommitUnzeroed | no allocation of a frame in flight, caught by the frames’ own check (a free frame not zero, or also in flight), not by a page read stale; a frame freed only zero |
| R12 (scheduling) | R12PriorityById, R12IgnoreWeight, R12WakeBanksCredit, R12TieQueuedFirst, R12RequeueAhead, R12RequeueLifo, R12PreemptOnWake, R12TimeoutWakePreempts, R12NoFloorWhenIdle, R12ShortRunsFree, R12DropRemainder, R12ExitRunsFree, R12DestroyDropsDebt, R12CreateAtFloorOnly, R12LiftByMax, R12StrideWeightIsLimit, R12UnnormalizedLift, R12LiftCountsEntryWait, R12FoldAtNewWeight, R12NoMinimumCharge, R12DeadlineWorkUnbilled, R12RescaleOnlyOnReturn, R12SliceCountsExitWork, R12TimerWorkUnbilled, R12SwitchBilledToPrevious, R12CappedHoldsFloor, R12CapOnce, R12AllCappedHoldsFloor, R12UncapBanksCredit, R12UncapForfeitsWait, R12EmptyHoldsFloor, R12RequeueWaitsForCap, R12BlockLeavesAsRequeued, R12OneRunnerPerBudget, R12SpreadChargesOnce | one flat queue, charging, the floor, ranks, preemption, the slice as user time, inheritance at create and destroy, the capped floor and the uncap lift across harts, a budget on several harts at once and each of its runners charged |
| R13 (one outcome per call) | IpcWrongLend, IpcDropPartial, IpcFalseDelivery, IpcSkipOutputCheck, IpcLeakRollback | the lend disposition, a partial reply, delivered, the completion-time record check, rollback |
| R14 (unforgeable sender) | MsgNoLabels, MsgBadgeZero, MsgAccountZero, MsgIdsGlobal | the attached labels, badge and account; message ids per receiving process |
| R18 (device authority) | R18DeviceByNumber, DeviceInfoWrongKind | a device reached only through a handle to it; device_info naming the device the handle names |
| R20 (PID reuse) | R20NoticePidReused | no PID reused while a notice names it |
| R21 (crash blame) | BlameNobody, BlameNewestCall, ExitWithOpenCallsNotFaulted, CurrentNeverSet, ReceiveKeepsCurrent, ServeIgnored, ExitEndpointBadged, ExitNoticeDroppedIfNoReceiver | blaming the current call’s sender; how receive and serve set the current call; a badge-0 exit endpoint; a notice kept until received |
| R22 (range cost) | R22MapFixedWalksFirst | map_fixed’s pages refused by arithmetic before any walk |
| I3 (minted badges are non-zero and narrow) | MintFromUnservedMessage | minting only from an open call of the caller’s own thread |
| I4 (only badge-0 handles receive) | ReceiveWithBadgedHandle | receive through a minted handle |
| I6 (labels only grow downward) | LabelsAddedByParentClass | adding labels needs a system-class caller |
| I8 (class and account inherited) | ClassNotInherited | a child’s class is its parent’s |
| I13 (every blocking call returns by its timeout) | TimeoutIgnoredWhileOthersRun, ExpireBudgetsFirst | timeouts expire while others run; at one instant, timeouts before deadlines |
| I16 (DMA pages reset before reuse) | DmaFreeBeforeReset, DmaQuarantinedSlotCountsAsReset, DmaUnmapFrees, DmaQuarantineChargeDropped, DmaQuarantinedDeviceUsable, DmaResetClearsCoHolderReach | pooling only after a confirmed reset, co-holders included; unmap keeping DMA frames; quarantine’s charge and sweep |
| R33 (no server holds a system budget) | PolicyServerHoldsSystemBudget | the steward starts with no system-class budget handle |
| R35 (key separation) | PolicyLoginWithKeydKey, PolicyApproveWithLoginKey | no login with a key keyd holds; no approval channel with a login key |
| R36 (unpredictable ids) | PolicySequentialIds | random ids |
| R37 (vault non-interference) | PolicyVaultWithoutOwnership, PolicyWriteUp, PolicyLabelledStartsAgent, PolicyAgentOtherSet, PolicyAuditUnfiltered, PolicyContextsAcrossSets | vault ownership, no write up, a labelled session starting nothing and asking for agents only in its own set, filtered audit reads, a session’s contexts listed and ended only in its own set |
| R38 (out-of-band approval) | PolicyApproveIgnoresHash, PolicyApproveOtherChannel, PolicyShowLabelledToAll, PolicyRenderNotWhitelisted, PolicyLabelledFreeTextShown, PolicyNotifyLabelledToAll, PolicyNoPendingCap, PolicyDeadSessionRequestsKept | approval of the frozen request on the channel that rendered it, by its owner; what the screen and the notices show; the pending cap |
| R39 (leases end) | PolicyUnboundedLease, PolicyEndLeaseAdmitted, PolicyEndLeaseFromVault, PolicySponsoredEndedByAnyone, PolicySponsoredVaultLogin, PolicyNoFairShare | bounded leases, ending always admitted and only from the sponsor’s unlabelled session (a sponsored principal’s session too, and never a labelled login of its), a fair share |
| R40 (blame by label set) | PolicyBlameNoWindow, PolicyNoLockout | the blame window and the lockout |
| R42 (one approved item) | PolicyDeclassifyLive, PolicyDeclassifyWithoutReader, PolicyDeclassifyFromUnlabelled, PolicyDeclassifyUnfit | a snapshot, through a reader or writer with the labelled side’s labels, from a session with them, of printable text within DECLASSIFY_MAX |
| R79 (one session per context) | PolicyContextTwice | a second login to a live context of the same name in its domain |
| R80 (one channel per context) | PolicyTakeoverBeforeAuth, PolicyBothAttached, PolicyStaleCloseDetaches | a takeover only after the login’s key is checked; the old channel let go before the new one attaches; a channel let go no longer named by its attachment id |
| R82 (the context cap) | PolicyNoContextCap, PolicyCapAcrossSets | the cap, and counting only the login’s own label set |
| R83 (idle contexts end) | PolicyIdleNoBound, PolicyIdleClockKept | the idle bound, and an attach clearing the clock |
Five of the steward’s former variants are retired, because the core’s types or another rule’s
keeper keep their rules and they cannot be written: blame or a pending cap counted per account and
a session carved from another label set’s sub-budget each need a second domain,
R41 (narrowing by revocation scope)’s
narrowing to a session’s budget needs a budget where the core’s Connect takes only a scope, and
an approval granting more than its approver holds needs a request whose labels its principal does
not own, which owns_labels refuses at entry and at submission (PolicyVaultWithoutOwnership)
(guards and effects).
Six rules are outside the model and have no variant: R15 (verified boot), R16 (image confinement), R17 (fail closed), R19 (kernel W^X), R23 (no test channels) and R24 (SUM and MXR clear). The model has no loader, no bundle, no kernel mappings of its own and no test build.
Traces
Status: built · tested (4)
- host:redoubt-model::traces_round_trip
- host:redoubt-model::a_rule_breaking_kernel_fails_replay
- host:redoubt-model::the_example_trace_is_what_the_model_does
- host:redoubt-model::hostile_traces_are_refused_cleanly
A trace is a run as ASCII text: the boot, then each event with the result the model gave.
trace::record writes one, trace::parse reads it back, and trace::check replays it on the
model and requires every line to match. The module is no_std and works on &str, so a
replayer running on the kernel can use trace::tokens as it is.
| Line | Meaning |
|---|---|
redoubt-model-trace 2 | the header; any other version is refused |
boot, costs, device | the boot budgets’ limits, the cost table, and each device object in init’s handle order |
start p:1 t:1 | init’s first thread |
do p:P t:T <call> <arguments> -> <result> | a call and its result, or blocked |
read, write, exec | a user load, store or fetch of one word: ok or fault |
fault, irq N, tick DT | a thread faults; a line is raised; time passes |
record p:P t:T <state> | a thread’s record changes validity, as when another thread of its process remaps it |
note | a process or thread the kernel created, naming its PID or TID |
wake p:P t:T -> <result> | a blocked call returns |
Names mark the values the kernel chooses, so a replayer can bind each to its own: p: a
PID, t: a TID, h: a handle index, m: a message id, a:BASE+OFF an address OFF bytes
into a region the kernel placed, pa: a physical address, tm: a time read (compared only for
order). A call’s result carries the three facts of R13: call status=... lend=none|returned|consumed reply=absent|present, with the reply’s words and handles only when present. A reply’s result is
ok reply delivery=delivered|discarded mask=N.
Results alone miss a kernel whose state has diverged but not yet shown it, such as a handle that
should have been revoked but is never used again. So random traces end with an epilogue
(check::epilogue): time passes until every finite timeout has returned; each process with a
runnable thread reads one word of every readable page it maps and the usage of every budget it
holds; init destroys the budgets it made, one at a time, reading usage after each; and each
live process closes every handle index from 1 to EPILOGUE_HANDLES (64). A thread blocked for
ever, a budget’s account (it travels only in messages) and an IRQ source’s mask stay unseen.
model/traces/lender-dies-mid-call.trace is the checked-in example: a client lends two pages to
a system server, and init destroys the client’s budget while the server holds them (R3). Its
end:
do p:61415 t:3 call h:1 [1,2,3,4] [] a:0x60000000+0x0@2 forever -> blocked
wake p:38622 t:2 -> ok message call m:1 badge=5 account=1001 labels=[] words=[1,2,3,4] handles=[] buffer=[lend,a:0x40000000,2]
read p:38622 t:2 a:0x40000000+0x0 -> ok word 42
write p:38622 t:2 a:0x40000000+0x0 99 -> ok
do p:1 t:1 budget_usage h:13 -> ok usage [32,10,1,1,50,0]
do p:1 t:1 budget_destroy h:12 -> ok
read p:38622 t:2 a:0x40000000+0x0 -> ok word 99
do p:1 t:1 budget_usage h:13 -> ok usage [32,10,1,1,50,0]
do p:38622 t:2 receive h:1 0 0 -> ok abandoned m:1
do p:38622 t:2 reply m:1 [0,0,0,0] [] -> ok reply delivery=discarded mask=0
do p:1 t:1 budget_usage h:13 -> ok usage [32,5,1,1,50,0]
do p:38622 t:2 receive h:1 0 0 -> ok exit p:61415 cause=killed code=0 blamed=0 blamed_labels=[]
Figure: the end of the example trace. The server keeps the lent pages after the lender’s budget is destroyed, is told the call was abandoned, and frees them by replying.
The tests:
traces_round_tripwrites 3,000 random sequences, reads each back to the same operations and replays it.a_rule_breaking_kernel_fails_replaylets a mutated model stand in for a kernel that breaks a rule. It replays 2,000 random traces with epilogues, plus seven scripted ones (a partial reply, current-call blame, the equal-instant expiry order, a quarantined device named again, a record gone bad as an abandoned-call notice or an interrupt comes, and a notice owed during a destruction), and requires some trace to fail for every variant that results can show. Not required, because results cannot show them: thePolicyvariants; R12’s, since scheduling shows only in timing;R5NoMaskOnFire, since every result is the same and the model’s own R5 check catches it; the three open-call-limit variants, since random traces never reach the limit (the flood does); andDmaResetClearsCoHolderReach, which only the I16 ghost check sees.the_example_trace_is_what_the_model_doesrebuilds the example and requires the same text.hostile_traces_are_refused_cleanlyfeeds fixed worst cases and 500 randomly damaged traces to the replayer. Each worst case must give an error; a damaged trace must not panic, and may still replay (a damaged line can be one the model accepts).
flowchart LR
S[seed] --> G[generator]
G --> K[kernel model:<br/>one step]
K --> C[checker:<br/>invariants, ghost]
C -- fails --> X[shrink and<br/>print the trace]
K --> R[trace::record]
R --> T[trace text]
T --> P[trace::check:<br/>replay on the model]
T -.-> Q[replayer on the<br/>real kernel]
Q -.-> V[every line<br/>matches]
Figure: how a run becomes a trace and where it is replayed. Solid parts are built; dashed parts are planned.
The steward model
Status: built · tested (6)
- host:redoubt-model::steward_policy
- host:redoubt-model::steward_noninterference
- host:redoubt-model::random_lineage_sequences_and_deliberate_rule_break
- host:redoubt-model::audit_authority_binds_purpose_signer_domain_length_and_every_byte
- host:redoubt-model::confined_read_down_and_owner_approved_one_item_snapshot_push
- host:redoubt-model::approve_and_deny_authenticate_direct_channels_before_any_effect
model/src/steward.rs embeds the steward’s policy core, the crate that ships
(the policy core), on the kernel model, and
model/src/policy.rs holds its properties. init creates the shared server’s endpoint and the
steward’s own, and starts the steward in system, with handles to users, system and both
endpoints. The steward starts the server, a system-class process standing in for littlefsd that
holds no budget handle, receiving on the shared endpoint. Every principal’s budget and its fixed
sub-budget per label set is a budget_create at boot. Then the core decides every call, and the
embedder runs each batch it returns as calls on the kernel model, in order, stopping at the first
failure, and reports it back. So each session and each agent’s lease is a budget carved from its
domain’s sub-budget, a revocation scope inside it, connections to the steward’s endpoint and the
server narrowed to that scope, and a process holding them. Sessions’ work is real calls to the
server; crash blame comes from the kernel’s exit notices of the server, and a session’s end from
those of its process. So every kernel check runs under everything the policy does. The embedder
keeps only its own half: admission, the volumes and their write check, an ideal keyd signing
every audit record, and entropy, one stream per domain for the events that draw ids. The policy is
described on the steward.
| Property | What must hold |
|---|---|
| sessions | a session’s or lease’s budget is carved from its domain’s sub-budget, with the principal’s account and the domain’s labels, which the principal owns |
| login | a login used one of the principal’s login keys, never one keyd holds |
| approvals | an approval channel opened with the principal’s approval key; an approval was answered on the channel that rendered the request last, named the hash it showed, which never changed, and granted no label the approver lacks |
| screens | a channel sees only its own principal’s requests, and labelled ones only if it owns every label; rendered text is printable ASCII with capped free text; a labelled request shows none of its free text; an approval-waiting notice reaches only sessions whose labels include the request’s |
| pending cap | at most PENDING_CAP (4) pending requests per domain, all from live sessions and agents; each holds at most its fair share |
| crossings | a declassification comes from a session with exactly the item’s labels and a push from an unlabelled one; what is copied out or pushed is exactly the snapshot taken at submission, a declassified item at most DECLASSIFY_MAX bytes of printable text, through a reader or writer budget with exactly the labelled side’s labels |
| crash blame | a domain’s sessions and leases end exactly when three server crashes blamed on it fall within ten minutes; nothing else is touched; nothing of it starts for the next ten minutes |
| labelled sessions | a labelled session or agent starts nothing; it only submits requests |
| leases | an agent’s budget has a deadline at most MAX_LEASE (24 hours) away; an expired lease is gone |
| non-interference | a vault session’s work changes nothing an unlabelled session observes: its results, its requests’ ids, the usage of users and of every principal’s budget and unlabelled sub-budget, and the audit records an unlabelled reader may read |
| writes | every write to an item is through a budget with exactly the item’s labels; the steward’s own write goes only to the unlabelled volume |
| system budgets | only init and the steward hold a handle to a system-class budget; a session’s connections are narrowed to a revocation scope inside its budget |
| leases end | a lease’s sponsor can always end it from an unlabelled session, and nothing else can |
| attachments | a context’s console is attached to at most one channel, exactly while it runs and under the one id that names it; a login attaches to a live context only with the principal’s key; a close of a channel the context was taken from changes nothing; a note a relay writes is printable |
| context cap | no domain holds more live contexts than its principal’s cap, and a login is refused cap only when its domain holds that many |
| idle contexts | on the timer, a detached context ends only once its principal’s bound has passed since its idle clock started, and has ended once it has; an attached one never ends on it |
| context commands | a session lists exactly its own domain’s live contexts; only an attached context lets its channel go; end_context ends a context of the caller’s own domain and no other, and always can |
steward_policy runs 20 to 160 random policy operations per seed and checks the properties and
the kernel’s checks after each, and that the steward never exits. Its manifest gives alice’s top
18 processes, so a label set’s share, a third, holds three sessions, one over the cap of two
contexts: some top must hold one more session than the cap, or the kernel’s refusal of the third
login for processes stands in for the cap’s and the cap rule is invisible to the model
(PolicyNoContextCap went uncaught when the one principal with a single label set, carol, became
sponsored and her sessions moved under alice’s share). Bob’s top keeps 12, a share holding two,
so his domains still fill and a start fails there. steward_noninterference runs
one sequence twice, the second time without the vault sessions’ work (their item writes, requests
and calls to the server, and the owner’s approvals and denials of their requests), and compares
everything an unlabelled session observes, the order the server takes its calls in included. The
other host tests pin connection lineage (a delegated badge shares its root’s pending share,
checked by an oracle that walks parent edges itself, with a deliberate break it must catch), audit
signatures bound to purpose, signer, domain, length and every byte, a confined session refused a
read of lower data while its owner pushes one approved item up, and approve and deny refused
on a channel that did not open, before any effect.
What the steward model leaves out: SSH (a login is “this key for this user name”), the approval
terminal (a channel is “a connection that authenticated with this key”), and real entropy: the
words come from a keyed mixer, where the server draws them from the kernel’s random. Audit
signatures are ideal tokens, and no chaining, truncation detection or ordering is claimed. The
volumes are maps the steward’s batches read and write directly; its reader and writer budgets
stand for the processes that would. The steward’s constants are the core’s: PENDING_CAP (4),
DECLASSIFY_MAX (256 bytes of printable ASCII), FIELD_CAP (64 characters), MAX_LEASE (24
hours), and the blame window (3 crashes in 10 minutes).
Where the model meets the kernel’s code
Status: built · tested: host:redoubt-stride::the_crate_and_the_model_agree, host:redoubt-stride::a_broken_model_disagrees, host:redoubt-ipclist::the_groups_follow_the_model
redoubt-stride (libs/stride) holds the stride arithmetic and ranks that the kernel links. Its
differential test drives the crate, wired as the kernel’s sched.rs calls it, and the model’s
Scheduler through the same random sequences: budget creations and destructions (a leaf whose
threads blocked first, the budget on the CPU, a whole subtree bottom-up), wakes, blocks, runs,
slice ends and preemptions, each on a hart drawn at random. Over 3,000 seeds at each of 1, 2 and
4 harts every pass, entry, remainder, tie, queue membership, the floor, the tie counters, the cap
set and each hart’s running thread must agree after every step; at 4 harts some 360,000 of the
steps hold two capped budgets or more. a_broken_model_disagrees shows the comparison bites:
with any of 30 of the 35 R12 variants planted in the model, some sequence disagrees (the ten of
several harts at 2 or 4 harts). It leaves out R12TimeoutWakePreempts, whose
site is the kernel model’s timer path, not the scheduler, and R12SliceCountsExitWork,
R12DeadlineWorkUnbilled, R12TimerWorkUnbilled and R12SwitchBilledToPrevious, kernel work
around a run (the exit work before a slice starts, a deadline’s destruction, a timer’s expiry, the
switch into a budget), which the differential does not drive; the model’s own checks catch
all five. The bench case stride-host-tests runs both tests. This compares one kernel crate with the
model, on the host; the kernel’s own use of it runs in boot cases
(scheduling).
redoubt-ipclist (libs/ipclist) holds the kernel’s IPC lists: the links of what waits on each
endpoint, device and budget, of the exit notices owed on an endpoint and the processes that
report there, of the process objects charged to each budget and counted in it, and of each
process’s waits with a deadline; R2’s groups in the order their turns
fall due, the expiry’s sort, and each list’s audit. The kernel links it and keeps every rule in
message.rs. the_groups_follow_the_model states R2 beside it as the model’s next_sender and
served do, and in 200 seeds of 300 random queues, leaves and takes each, the two must pick the
same message, for a receiver below MAX_OPEN_CALLS and for one at it. The host-tests case runs
it.
Replaying traces on the real kernel
Status: planned · M3 (agents, approvals and the attack suite)
A bench case boots the real kernel with a replayer program and a set of model traces as files
in the bundle (test bench). The replayer reads each trace with
trace::tokens, binds each name to the value the kernel gives, makes each call from the process
and thread the line names, and compares every result, note and wake, the epilogue included. A
trace passes only if every line matches. A line the replayer cannot drive is a failure, never a
pass, and a trace that exercises a question the design has not settled does not count until it
is settled. Usage is compared under the model’s placement of page tables. Traces include the
timer-driven rows of the completion table (a taken call timing out, a budget deadline firing),
run on the real timer. The case passes when 100,000 model traces replay with identical results.
Replay is what turns the model from a reference into evidence about the kernel.
Open: how the replayer gets init’s boot handles and devices without an interface that exists only for testing; how tick, irq, fault and record lines are produced on the real machine; the boot sizes (the model’s fixed limits against the kernel’s, which follow RAM); map_device’s result (the kernel returns the address and the length, the model only the address); a receive record that passes decoding but faults when written (Record::CopyFault), which has no kernel counterpart and stays out of traces; completion races between harts, which a sequential trace cannot express; scheduling and IRQ masking, which results do not show.
Residual risks
- The model checks its own abstraction. Properties that hold and mutations that are caught show that the model agrees with itself and that its checks bite on it. They say nothing about the kernel. No trace has been replayed on the real kernel, so every “attacked only in the model” on the kernel pages rests on the model and the kernel agreeing, which nothing has shown. A model that has drifted from the design keeps passing its own tests; only replay or a reader finds the drift.
- Host tests do not reach the kernel’s boundaries. Timer-driven cancellation, the kernel’s
locking and completion races between harts are outside the model: a step is atomic and time
is a counter. Passing model runs establish none of them. The kernel crate itself has no host
tests: its binary says so (
test = false), and its rules are tested on the target by the bench. - One boot case copies model results by hand.
tests/programs/src/bin/budget-test.rsruns a short budget sequence whose expected results were read off the model. Nothing re-derives them from the model, so a change to the model leaves them stale without a failure. - Finite worlds. The default run is 65,020 sequences on small boots (the testing boot’s
roothas 1,024 pages and 24 processes), and random record changes target blocked calls only. Sizes and mapping geometry bound what the runs explore. The million-sequence run is a separate test that the bench does not run. - The steward model’s embedder is the model’s own. The core it runs ships, but the server’s embedder (transport, admission, running batches) is not this one. Its entropy is ideal, and the non-interference comparison leaves out a server crash on its own and one a vault’s call causes: which call such a crash blames, and when the server takes the calls before it, is service timing, a stated residual of R37 (vault non-interference).
- No property checks that a takeover tells the old channel. The attachments property checks
that every note a relay is given is printable, not that a takeover gives the old channel one, so
a core that let the old channel go in silence would pass; no mutation drops the note. The
server’s host test
a_login_to_an_attached_context_takes_it_overand the bench’ssteward-context-logincheck the note. - The steward families reach no failed crossing, and no login to a starting context. Neither
family, searched to 20,000 seeds, makes a declassification’s copy out or a push fail, so the
core’s effects for a failed crossing (
audit_copy_failed,audit_push_failed,pass_failure) never run in the model and no property is checked on their outcome. The model runs a session’s batch to its end within the login, so no second login finds a context still starting, and thein_userefusal (refuse_in_use) runs only in the core’s host tests. The model has no console session, so every session it detaches is a context and the ruleis_contextnever refuses; its refusal runs only in the core’s host tests too.steward_policynever calls the audit filter (audit_visible), which onlysteward_noninterference’s audit view reaches. The counts are set from what the families reach (property families), which bounds what a lower count gives up, not what the generator never builds. The four breaks whose state the random search builds only by chance, or too slowly, (PolicyDeclassifyUnfitat the policy family’s seed 1,418) are caught first by directed scenarios, which pin the state each break needs rather than show that the generator finds it. - Rules outside the model (R15, R16, R17, R19, R23, R24) have no model check at all; their boot cases are their only attack.
Why
- Independent of the kernel’s source. A model that shared code with the kernel would share its bugs. Its one dependency is the steward’s policy core, which it embeds so that the steward’s families attack the code that ships. Otherwise the model can be read side by side with the design, and every failure is reproduced from one seed.
- Ghost state apart from the kernel model. The checks read facts recorded at each event from the primary objects, never the kernel model’s own counters, so a wrong update cannot also correct the check that should catch it.
- Mutations, because a property can hold for the wrong reason. A check that nothing reaches passes every seed. A planted break that no property catches names the gap, and every rule keeps at least one planted break so that a weakened check shows up as a surviving variant.
- Text traces, with an epilogue. Text is easy to diff, to shrink and to carry into a bundle,
and a
no_stdparser serves the model and a replayer alike. The epilogue exists because a kernel can diverge silently; probing memory, usage and handle tables at the end makes the divergence show in a result. - The steward on the kernel model, not beside it. Running the policy on real modelled calls means every kernel invariant holds under everything the policy does, and non-interference is judged on what the kernel actually returns.