diff --git a/docs/analysis/reviews/business-reviews/2026-04-21-A2-completion.md b/docs/analysis/reviews/business-reviews/2026-04-21-A2-completion.md new file mode 100644 index 0000000..395b433 --- /dev/null +++ b/docs/analysis/reviews/business-reviews/2026-04-21-A2-completion.md @@ -0,0 +1,77 @@ +# Business review 2026-04-21 — A2 completion + +- **Trigger:** milestone-completion +- **Scope:** Milestone A2 — Capability table foundation +- **Period:** 2026-04-20 (roadmap-system establishment, commit `abe1b94`) → 2026-04-21 (T-001 landed on `main` via PR #1) +- **Participants:** @cemililik (+ Claude Opus 4.7 agent as scribe) + +## What landed + +### Commits (reverse chronological, A2-relevant) + +| SHA | Date | Subject | Advances | +|-----|------|---------|----------| +| `75ca576` | 2026-04-21 | `docs(roadmap): T-001 → Done; A2 closed; advance current.md` | closes A2 | +| `e937537` | 2026-04-21 | `docs(adr): propose ADR-0016 — Kernel object storage` | forward into A3 | +| `a587761` | 2026-04-21 | `docs(roadmap): open T-002 — Kernel object storage foundation (Draft)` | forward into A3 | +| `2e1d943` | 2026-04-21 | `docs: apply second-round PR review nits` | T-001 review round 2 | +| `cd8511c` | 2026-04-21 | `fix(cap): apply PR review findings to the capability subsystem` | T-001 review round 1 (code) | +| `d7ff460` | 2026-04-21 | `docs: apply PR review feedback, renumber reserved ADRs, add WOSR-derived patterns` | T-001 review round 1 (docs) + forward patterns | +| `e58a235` | 2026-04-21 | `chore: gitignore local technical-analysis notes` | housekeeping | +| `95db0f4` | 2026-04-20 | `docs(roadmap): AI integration stance (ADR-0015) + Phase J + supporting updates` | Plan C accepted | +| `8fe59d0` | 2026-04-20 | `feat(kernel): implement capability table (T-001)` | T-001 main implementation | +| `c574a12` | 2026-04-20 | `docs(adr): propose ADR-0014 — capability representation; T-001 → In Progress` | T-001 start | + +### ADRs + +- **ADR-0014 — Capability representation** (Accepted 2026-04-20). Index-based arena with generation-tagged handles, move-only `Capability`, narrowing-only rights. +- **ADR-0015 — AI integration stance** (Accepted 2026-04-20). Kernel stays AI-neutral; AI features live opt-in in userspace per Plan C. +- **ADR-0016 — Kernel object storage** (Proposed 2026-04-21 — snapshot; Accepted the same day). Per-type fixed-size-block arenas with typed handles, mirroring the capability-table shape. + +### Tasks reaching `Done` + +- **T-001 — Capability table foundation.** Shipped `cap::CapabilityTable`, `cap::CapHandle`, `cap::CapRights`, `cap::Capability`, `cap::CapError`. Zero `unsafe`, no heap, 29 host tests (14 rights, 15 table) green. `CapObject` placeholder encapsulated via `new`/`raw`. + +## What changed in the plan + +- **Roadmap + analysis system established** (commit `abe1b94`, predates this review period but relevant context): ten phases, per-phase task folders, four typed review folders each with a master plan. See [ADR-0013](../../../decisions/0013-roadmap-and-planning.md). +- **Phase J added** alongside ADR-0015. The phase plan grew from nine phases to ten; [`phases/README.md`](../../../roadmap/phases/README.md) and [`roadmap/README.md`](../../../roadmap/README.md) were updated to match. +- **ADR renumber cascade.** ADR-0015 was originally reserved for A3 "Kernel object storage" but was taken by the AI-integration decision that landed out of sequence. A3/A4/A5 reservations shifted +1 (A3 → 0016, A4 IPC → 0017, A4 Badge → 0018, A5 Scheduler → 0019, A5 Cpu v2 → 0020); Phase B–I reservations shifted +1 each. The contiguous reservation range is now 0012–0057. +- **WOSR-derived pattern notes inserted** into three phases' sub-breakdowns: + - Phase A3 — fixed-size-block allocator per kernel-object kind (applied in ADR-0016). + - Phase B2 — typed `MapperFlush`-analog acknowledgement token on the `Mmu` trait. + - Phase C3 — closure-based `Cpu::without_interrupts` HAL primitive for IRQ-masked critical sections. +- **T-002 opened** in `Draft` status for Milestone A3. + +## What we learned + +**Unplanned ADR insertions ripple through reserved numbers.** The ADR-0015 AI-integration decision landed between the original reservation pass and A3, forcing a +1 shift across Phase A–I. The fix was mechanical but touched 9 phase files. Future unplanned ADRs should either (a) take the *next* free number rather than displacing a reservation, or (b) accept the ripple up front. Phase-a.md already notes "Numbers may shift if unexpected decisions land in between" — that is the accurate mental model, and the ledger should be treated as intent, not a promise. + +**The WOSR analysis format earned its keep.** Reading Philipp Oppermann's *Writing an OS in Rust* produced three concrete patterns (typed flush tokens, `without_interrupts` primitive, fixed-size-block arenas) that were worth naming in advance of the code that will use them. ADR-0016 adopts one of those patterns (fixed-size-block arenas) directly. A similar study of seL4 or Hubris before Phase B is likely worth doing, again kept local via `.gitignore`. + +**Reviews caught semantic bugs that tests did not.** Two findings in T-001's review were not caught by the 29 kernel tests: +- `cap_drop` of an interior node orphaned its children — a correctness bug the tests happened not to trigger because every `drop` test was against a leaf. Fixed with `CapError::HasChildren` plus a new test. +- `CapRights::from_raw` accepted any `u32`, letting reserved bits smuggle themselves past subset checks. Fixed with a `KNOWN_BITS` mask. + +Both bugs were present under good test coverage of the *typed-error* paths. The lesson: typed-error coverage is not semantic-invariant coverage. Future tasks should enumerate *invariants to uphold* as explicit acceptance criteria, not just "operations return the documented errors". + +**Encapsulation-by-default saved us later.** The second review round flagged `CapObject(pub u64)` as too open even for a placeholder. Changing the field to private with `new`/`raw` accessors is trivial today and will be load-bearing when ADR-0016 replaces the placeholder with a typed enum — every construction site is already auditable. Same lesson applies to other placeholder types: keep fields private from day one. + +**Two review rounds despite "measured pace".** Round 1 addressed correctness; round 2 addressed cross-file consistency (Rust module-path style `::` hyphens vs. underscores, "Pi 4" vs. "Pi 5", "markdown" vs. "Markdown", MMU-capable board filtering for the RISC-V BSP, untagged code fence). A pre-commit self-review pass on the diff — specifically comparing wording across all changed files — would likely catch most of these. Worth adopting as part of the commit workflow. + +**Zero-`unsafe` target was achievable.** The capability table is 350 lines of safe Rust. The zero-`unsafe` goal from ADR-0014 held. This is a useful data point for ADR-0016: if the kernel-object arenas follow the same pattern, they should also be `unsafe`-free. + +## Adjustments + +- [ ] **Open a standards doc** — `docs/standards/kernel-api-conventions.md` — capturing the "encapsulation-by-default, typed non-exhaustive errors, `new`/`raw` accessor pattern" conventions the capability subsystem now embodies. Trigger: before T-002 implementation begins, so the same conventions are applied from the start. Execution: `propose-standard-change` skill. +- [ ] **Tighten task acceptance criteria** to enumerate semantic invariants, not just error-return paths. Trigger: when T-002 moves from `Draft` to `Ready`, revisit its acceptance criteria against this pattern. Execution: inline edit during the T-002 transition. +- [ ] **Repeat the WOSR-analysis exercise** for one of: seL4, Hubris, Theseus, NuttX. Trigger: before Phase B (userspace) starts — seL4 is the most relevant prior art for userspace / address-space / syscall design. Execution: local under `docs/analysis/technical-analysis/` (gitignored), with any extracted patterns surfaced into phase-b sub-breakdowns the same way WOSR was. +- [ ] **Add a pre-commit diff-scan step** to the maintainer's workflow: before staging, scan every changed file for cross-file wording/style consistency. Does not need to block; a five-minute re-read. Execution: informal; may grow into a checklist in `CONTRIBUTING.md` if the value becomes clear. +- [ ] **ADR-0016 → Accepted** before T-002 implementation code lands. Trigger: maintainer review of the Proposed ADR. Execution: status edit on [`ADR-0016`](../../../decisions/0016-kernel-object-storage.md) + a one-line commit. + +## Next + +- **Active phase:** A +- **Active milestone:** A3 — Kernel objects +- **Active task:** [T-002 — Kernel object storage foundation](../../tasks/phase-a/T-002-kernel-object-storage.md) (Draft → Ready after this review is committed, then → In Progress once ADR-0016 is Accepted) +- **Next review trigger:** code + security review of the T-002 implementation when it reaches `In Review`; business review waits for A6 per [phase-a.md closure](../../../roadmap/phases/phase-a.md). diff --git a/docs/analysis/reviews/business-reviews/README.md b/docs/analysis/reviews/business-reviews/README.md index 3a24f52..a23fd44 100644 --- a/docs/analysis/reviews/business-reviews/README.md +++ b/docs/analysis/reviews/business-reviews/README.md @@ -22,8 +22,6 @@ A business review may point at outcomes from those other reviews as part of "wha ## Index -_No reviews yet._ The first business review will accompany the completion of Milestone A2. - | Date | Scope | File | |------|-------|------| -| _pending_ | Milestone A2 | — | +| 2026-04-21 | Milestone A2 — Capability table foundation | [2026-04-21-A2-completion.md](2026-04-21-A2-completion.md) | diff --git a/docs/analysis/tasks/phase-a/README.md b/docs/analysis/tasks/phase-a/README.md index 97bacd7..d588894 100644 --- a/docs/analysis/tasks/phase-a/README.md +++ b/docs/analysis/tasks/phase-a/README.md @@ -6,6 +6,7 @@ Tasks belonging to [Phase A — Kernel core on QEMU `virt`](../../../roadmap/pha | ID | Title | Milestone | Status | |----|-------|-----------|--------| -| [T-001](T-001-capability-table-foundation.md) | Capability table foundation | A2 | In Review | +| [T-001](T-001-capability-table-foundation.md) | Capability table foundation | A2 | Done | +| [T-002](T-002-kernel-object-storage.md) | Kernel object storage foundation | A3 | In Review | Tasks are added here as they become active. See [`../../../roadmap/phases/phase-a.md`](../../../roadmap/phases/phase-a.md) for the full phase plan. diff --git a/docs/analysis/tasks/phase-a/T-001-capability-table-foundation.md b/docs/analysis/tasks/phase-a/T-001-capability-table-foundation.md index c0e2402..69aacab 100644 --- a/docs/analysis/tasks/phase-a/T-001-capability-table-foundation.md +++ b/docs/analysis/tasks/phase-a/T-001-capability-table-foundation.md @@ -2,7 +2,7 @@ - **Phase:** A - **Milestone:** A2 — Capability table foundation -- **Status:** In Review +- **Status:** Done - **Created:** 2026-04-20 - **Author:** @cemililik - **Dependencies:** none (Milestone A1 — Bootable skeleton — is complete) @@ -25,23 +25,23 @@ This task deliberately stops short of introducing the kernel objects capabilitie ## Acceptance criteria -- [ ] **ADR-0014 Accepted.** Defines: in-kernel capability representation (struct layout, rights bits, object-reference encoding), handle type exposed to callers, derivation-tree storage (intrusive vs. index-based), per-task bound on table size, and the error type for operations. -- [ ] **`CapabilityTable` type** in a new `umbrix_kernel::cap` module. Bounded capacity (compile-time or per-instance), no heap allocation. -- [ ] **`Capability` type** (enum or struct with a kind field) covering the v1 placeholder variants. Concrete object references are placeholders until Milestone A3 replaces them — the point is the *table's* correctness, not the objects'. -- [ ] **Rights** (`CapRights` or similar) represented as a bitfield with the operations exposed so far: duplicate, derive, revoke, transfer-on-IPC (placeholder — no IPC yet). -- [ ] **Handle-based access.** Callers receive a `CapHandle` (opaque index); raw capability bits are never exposed. -- [ ] **Four operations** implemented: +- [x] **ADR-0014 Accepted.** Defines: in-kernel capability representation (struct layout, rights bits, object-reference encoding), handle type exposed to callers, derivation-tree storage (intrusive vs. index-based), per-task bound on table size, and the error type for operations. +- [x] **`CapabilityTable` type** in a new `umbrix_kernel::cap` module. Bounded capacity (compile-time or per-instance), no heap allocation. +- [x] **`Capability` type** (enum or struct with a kind field) covering the v1 placeholder variants. Concrete object references are placeholders until Milestone A3 replaces them — the point is the *table's* correctness, not the objects'. +- [x] **Rights** (`CapRights` or similar) represented as a bitfield with the operations exposed so far: duplicate, derive, revoke, transfer-on-IPC (placeholder — no IPC yet). +- [x] **Handle-based access.** Callers receive a `CapHandle` (opaque index); raw capability bits are never exposed. +- [x] **Four operations** implemented: - `cap_copy(src, narrower_rights) -> Result` — install a peer in the caller's table with the same or narrower rights. - `cap_derive(src, narrower_scope) -> Result` — install a child capability whose scope is strictly narrower; record the parent-child relationship. - `cap_revoke(src) -> Result<(), CapError>` — invalidate the derivation subtree rooted at `src`. - `cap_drop(handle) -> Result<(), CapError>` — release a capability from the caller's table with no effect on others. -- [ ] **Move-only discipline.** The `Capability` type must not be `Copy` or `Clone`. Duplication is strictly through `cap_copy` or an explicit in-kernel duplication operation; the Rust type system enforces this. -- [ ] **Rights narrowing invariant.** `cap_copy` and `cap_derive` cannot broaden rights; a test demonstrates that attempting to widen returns an error. -- [ ] **Revocation cascade.** A test constructs a derivation tree of depth ≥ 3 and verifies that revoking a parent invalidates all descendants atomically. -- [ ] **Bounded state.** A test fills the capability table to capacity and confirms the next insert returns `CapError::CapsExhausted` rather than panicking or allocating. See [architectural-principles.md — bounded kernel state](../../../standards/architectural-principles.md) and [security-model.md — Bounded kernel resources](../../../architecture/security-model.md). -- [ ] **Documentation:** new rustdoc on every public item; no `missing_docs` warnings. -- [ ] **Tests:** unit tests in the kernel module (using `#[cfg(test)]`), plus any integration tests that need `test-hal` fakes. -- [ ] **No new `unsafe`** if achievable; if any is required, audit-log entry per [`unsafe-policy.md`](../../../standards/unsafe-policy.md). +- [x] **Move-only discipline.** The `Capability` type must not be `Copy` or `Clone`. Duplication is strictly through `cap_copy` or an explicit in-kernel duplication operation; the Rust type system enforces this. +- [x] **Rights narrowing invariant.** `cap_copy` and `cap_derive` cannot broaden rights; a test demonstrates that attempting to widen returns an error. +- [x] **Revocation cascade.** A test constructs a derivation tree of depth ≥ 3 and verifies that revoking a parent invalidates all descendants atomically. +- [x] **Bounded state.** A test fills the capability table to capacity and confirms the next insert returns `CapError::CapsExhausted` rather than panicking or allocating. See [architectural-principles.md — bounded kernel state](../../../standards/architectural-principles.md) and [security-model.md — Bounded kernel resources](../../../architecture/security-model.md). +- [x] **Documentation:** new rustdoc on every public item; no `missing_docs` warnings. +- [x] **Tests:** unit tests in the kernel module (using `#[cfg(test)]`), plus any integration tests that need `test-hal` fakes. +- [x] **No new `unsafe`** if achievable; if any is required, audit-log entry per [`unsafe-policy.md`](../../../standards/unsafe-policy.md). ## Out of scope @@ -73,14 +73,14 @@ Every step keeps `cargo host-test` green. ## Definition of done -- [ ] `cargo fmt --all -- --check` clean. -- [ ] `cargo host-clippy -- -D warnings` clean. -- [ ] `cargo kernel-clippy` clean (the kernel builds for aarch64 with the new code). -- [ ] `cargo host-test` passes with the new tests; coverage-of-contract is readable from the test names. -- [ ] Any new `unsafe` has an audit entry per [`unsafe-policy.md`](../../../standards/unsafe-policy.md). Ideally none. -- [ ] Commit(s) follow [`commit-style.md`](../../../standards/commit-style.md). At minimum: ADR-0014 as one commit, implementation as one commit. Trailers `Refs: ADR-0014, ADR-0001`. -- [ ] [`../../../roadmap/current.md`](../../../roadmap/current.md) updated on status transitions (to `In Progress`, then `In Review`, then `Done`). -- [ ] Milestone A2 business review written after this task is Done, per [`conduct-review`](../../../../.claude/skills/conduct-review/SKILL.md). +- [x] `cargo fmt --all -- --check` clean. +- [x] `cargo host-clippy -- -D warnings` clean. +- [x] `cargo kernel-clippy` clean (the kernel builds for aarch64 with the new code). +- [x] `cargo host-test` passes with the new tests; coverage-of-contract is readable from the test names. +- [x] Any new `unsafe` has an audit entry per [`unsafe-policy.md`](../../../standards/unsafe-policy.md). Ideally none. +- [x] Commit(s) follow [`commit-style.md`](../../../standards/commit-style.md). At minimum: ADR-0014 as one commit, implementation as one commit. Trailers `Refs: ADR-0014, ADR-0001`. +- [x] [`../../../roadmap/current.md`](../../../roadmap/current.md) updated on status transitions (to `In Progress`, then `In Review`, then `Done`). +- [x] Milestone A2 business review written after this task is Done, per [`conduct-review`](../../../../.claude/skills/conduct-review/SKILL.md). ## Design notes @@ -105,3 +105,5 @@ Every step keeps `cargo host-test` green. | 2026-04-20 | @cemililik | opened; status Ready | | 2026-04-20 | @cemililik | ADR-0014 Accepted; status → In Progress; work begins on `development` branch | | 2026-04-20 | @cemililik | implementation landed on `development`; status → In Review. 27 new host tests green on top of the existing 34 (61/61 total). | +| 2026-04-21 | @cemililik | review-round code/doc fixes landed; CapRights masks reserved bits, `cap_drop` rejects interior nodes with `HasChildren`, `CapObject` encapsulated (`new`/`raw`), BFS uses `debug_assert` for invariants, `cap_derive` cleaned up; two new host tests added (29 kernel + 34 test-hal = 63/63 green). | +| 2026-04-21 | @cemililik | PR #1 merged to `main`; status → Done. | diff --git a/docs/analysis/tasks/phase-a/T-002-kernel-object-storage.md b/docs/analysis/tasks/phase-a/T-002-kernel-object-storage.md new file mode 100644 index 0000000..346df0d --- /dev/null +++ b/docs/analysis/tasks/phase-a/T-002-kernel-object-storage.md @@ -0,0 +1,101 @@ +# T-002 — Kernel object storage foundation + +- **Phase:** A +- **Milestone:** A3 — Kernel objects +- **Status:** In Review +- **Created:** 2026-04-21 +- **Author:** @cemililik +- **Dependencies:** T-001 — Capability table foundation (must reach `Done`; A3 is blocked on A2 closure per [phase-a.md](../../../roadmap/phases/phase-a.md)) +- **Informs:** future tasks under Milestone A4 (IPC primitives) — IPC needs `Endpoint` and `Notification` kernel objects to dispatch against. +- **ADRs required:** ADR-0016 (kernel object storage) — **Accepted** 2026-04-21. + +--- + +## User story + +As the Umbrix kernel, I want typed kernel-object arenas (`Task`, `Endpoint`, `Notification`) reachable from the capability table, so that capabilities point at real in-kernel entities with well-defined lifecycle — replacing the opaque `CapObject(u64)` placeholder from [ADR-0014](../../../decisions/0014-capability-representation.md) with structure that the IPC path (Milestone A4) can dispatch against. + +## Context + +[T-001](T-001-capability-table-foundation.md) landed the capability table but left `CapObject` as a placeholder `u64`. [ADR-0014](../../../decisions/0014-capability-representation.md) explicitly defers the "what does a capability point at" question to A3. Until kernel objects exist, a capability asserts authority over *nothing concrete*: every later subsystem — IPC endpoints, notifications, tasks, memory regions — needs an object to name. + +T-002 introduces the minimum set of kernel-object kinds Phase A requires: `Task` (so future scheduler work has something to schedule), `Endpoint` (so A4's IPC has a rendezvous point), `Notification` (so A4's async signalling has a target). Memory regions are deferred to Phase B's MMU work. + +This task is **structure and lifecycle only**, not behaviour. The scheduler (A5), the context switch (A5), and the IPC send/recv paths (A4) all build on the storage A3 introduces, but do not land inside A3. + +## Acceptance criteria + +- [x] **ADR-0016 Accepted.** Settles storage strategy (per-type arena vs. shared pool), handle type (generation-tagged or equivalent), ownership (global vs. per-task), and explicit-destruction semantics. +- [ ] **Kernel-object module** `umbrix_kernel::obj` (or equivalent) with three types: `Task`, `Endpoint`, `Notification`. Minimal fields per ADR-0016; no scheduler / IPC logic. +- [ ] **Per-type arenas** with bounded capacity (compile-time constants, revisited when a real use case demands more). +- [ ] **Handle types** (`TaskHandle`, `EndpointHandle`, `NotificationHandle`) or a unified typed handle — whichever ADR-0016 chooses. Use-after-destroy structurally impossible (generation check, typed arena, or equivalent). +- [ ] **`CapObject` wiring.** The placeholder `CapObject` becomes a typed reference to a kernel object (enum over the three kinds, carrying the appropriate handle). [ADR-0014](../../../decisions/0014-capability-representation.md) said the outer API of the capability table does not change; this task verifies that claim holds. +- [ ] **Create / destroy APIs.** + - `create_task(initial_state) -> Result` + - `destroy_task(handle) -> Result<(), ObjError>` (symmetric; fails if already destroyed or if any capability still names it — exact policy per ADR-0016). + - Symmetric pair for `Endpoint` and `Notification`. +- [ ] **Capability flow.** Creating a kernel object produces the capability that names it (with all v1 rights on that kind); dropping the last such capability's behaviour is per ADR-0016 (explicit destruction vs. reference-counted). +- [ ] **Host tests** covering: create / lookup / destroy happy path; handle invalidation after destroy; arena exhaustion returns typed error; capability-to-object lookup resolves correctly; dropping capabilities vs. destroying objects produces the ADR-0016-documented outcome. +- [ ] **No new `unsafe`** if achievable. If any lands, an audit entry per [`unsafe-policy.md`](../../../standards/unsafe-policy.md). +- [ ] **Move-only where correctness demands.** Kernel-object types that encode ownership (e.g., "the endpoint's blocked-sender list") should not be `Copy` / `Clone`; the compiler enforces the ownership story. + +## Out of scope + +- Scheduler interaction with `Task` (A5). +- IPC send / recv against `Endpoint` and `Notification` (A4). +- Capability transfer through IPC (A4). +- `MemoryRegion` kernel object (Phase B — lands with the MMU). +- Per-task ownership of kernel objects (Phase B introduces the "task owns an address space owns memory-region caps" chain). +- Reference counting on the hot path if ADR-0016 picks explicit destruction. + +## Approach + +Sketch; real design in ADR-0016. + +1. **Storage.** Per-type fixed-size-block arena — one array of `Task`, one of `Endpoint`, one of `Notification`, each with index-based generation-tagged handles mirroring the [`CapabilityTable`](../../../kernel/src/cap/table.rs) pattern from T-001. Consistency with the already-audited capability table is valuable: one shape, three instances. A shared pool alternative is weighed in the ADR and should be rejected unless it earns its place. +2. **Ownership.** Arenas are global (kernel-scope), not per-task, because a single kernel object is reachable from many capabilities across many tasks. Single-core v1 needs no synchronization. +3. **Lifecycle.** Explicit destruction via a `Destroy`-righted capability (or a dedicated kernel path). The cascade "last capability drop destroys the object" is seductive but introduces reference counting that v1 does not need; defer until a concrete use case forces it. +4. **CapObject wiring.** `CapObject` becomes an enum variant per `CapKind`, each carrying its typed handle: + ```rust + enum CapObject { + Task(TaskHandle), + Endpoint(EndpointHandle), + Notification(NotificationHandle), + // MemoryRegion lands in Phase B + } + ``` + [ADR-0014](../../../decisions/0014-capability-representation.md) reserved room for this migration; A3 makes it real. + +## Definition of done + +- [ ] `cargo fmt --all -- --check` clean. +- [ ] `cargo host-clippy` clean. +- [ ] `cargo kernel-clippy` clean. +- [ ] `cargo host-test` passes with the new tests. +- [ ] No new `unsafe` without an audit entry. +- [ ] Commit(s) follow [`commit-style.md`](../../../standards/commit-style.md); at minimum: ADR-0016 as one commit, `umbrix_kernel::obj` module as one commit, `CapObject` wiring as one commit. +- [ ] [`current.md`](../../../roadmap/current.md) updated on each status transition. +- [ ] Milestone A3 is **not** closed by this task alone — A3 closes when subsequent work (if any) lands. Current plan: T-002 covers A3 in one task. + +## Design notes + +- **Why not a shared `KernelObject` enum arena?** A single enum carries the size of its largest variant for every slot; `Task` is likely to grow faster than `Endpoint` (scheduler state, context frame). Per-type arenas let each grow independently. The consistency of "one arena shape, three instances" also lowers audit cost. +- **Why explicit destroy instead of drop-on-last-ref?** Reference counting on capabilities needs a revoke-aware decrement path that is non-trivial to get right under concurrent use (multi-core Phase C). Explicit destruction is deterministic, auditable, and can be extended to ref-counted later behind the existing outer API if warranted. +- **Sizing.** v1 arenas pick conservative compile-time bounds — `16` each is enough for Phase A smoke tests. Revisit when A4 / A5 / A6 have real numbers. +- **Handle safety.** Typed handles prevent the most common misuse: passing a `TaskHandle` where an `EndpointHandle` is expected. The compile-time distinction is cheap and catches entire classes of bug. + +## References + +- [ADR-0014: Capability representation](../../../decisions/0014-capability-representation.md) +- [ADR-0016: Kernel object storage](../../../decisions/0016-kernel-object-storage.md) *(Proposed — to be Accepted before code lands)* +- [Phase A plan](../../../roadmap/phases/phase-a.md) +- [seL4 kernel-object model](https://sel4.systems/) — per-kind kernel-object design, untyped-to-typed retyping (not adopted here; referenced for comparison). +- [Hubris task/cell storage](https://hubris.oxide.computer/) — compile-time-bounded per-type arenas; direct shape parallel. + +## Review history + +| Date | Reviewer | Note | +|------|----------|------| +| 2026-04-21 | @cemililik | opened; status Draft (A3 blocked until A2 Done) | +| 2026-04-21 | @cemililik | A2 Done + A2 business review committed; status → Ready. ADR-0016 Accepted the same day; implementation may begin. | +| 2026-04-21 | @cemililik | implementation landed on `development`; status → In Review. `umbrix_kernel::obj` module (generic `Arena`, `Task`/`Endpoint`/`Notification` + typed handles + create/destroy APIs); `CapObject` rewired to a typed enum; `Capability` loses its redundant `kind` field (derived from object). 14 new host tests on top of the 63 T-001 baseline (77/77 total). | diff --git a/docs/decisions/0016-kernel-object-storage.md b/docs/decisions/0016-kernel-object-storage.md new file mode 100644 index 0000000..c292fd2 --- /dev/null +++ b/docs/decisions/0016-kernel-object-storage.md @@ -0,0 +1,239 @@ +# 0016 — Kernel object storage + +- **Status:** Accepted +- **Date:** 2026-04-21 +- **Deciders:** @cemililik + +## Context + +[T-002](../analysis/tasks/phase-a/T-002-kernel-object-storage.md) opens the kernel-object subsystem in `umbrix-kernel`. Before any implementation code lands, the concrete shape of a kernel object needs to be settled: **how it is stored, who owns it, how its lifecycle is managed, and how capabilities refer to it**. [ADR-0014](0014-capability-representation.md) deferred these questions by representing the object-reference field as an opaque `CapObject(u64)` placeholder; [Milestone A3 in phase-a.md](../roadmap/phases/phase-a.md) is the place where the placeholder gets replaced. + +The decision compounds. Every Phase A subsystem that follows — IPC (A4), scheduler (A5), two-task demo (A6) — will reference kernel objects through the representation chosen here. Changing the representation once callers exist is painful: the capability system, the IPC path, and the scheduler all depend on the handle shape. + +The guiding context: + +- [ADR-0001](0001-microkernel-architecture.md) commits Umbrix to a capability-based microkernel — **every kernel resource is an object, every action against it goes through a capability**. There is no "kernel-global implicit state" kernel objects can live in; they must be explicit, typed, and countable. +- [ADR-0014](0014-capability-representation.md) established the shape of the capability table: per-task fixed-size arena with generation-tagged handles, `unsafe`-free. Its pattern is already audited and covered by 29 host tests. +- [`architectural-principles.md`](../standards/architectural-principles.md) mandates **bounded kernel state** — no unbounded growth, no heap surprises. +- **Single-core v1.** No cross-core concurrency yet; per-core state and atomics are deferred to Phase C. +- The WOSR-derived pattern note in [phase-a.md §A3](../roadmap/phases/phase-a.md) flags fixed-size-block per kernel-object kind as the pattern to weigh first. + +T-002 introduces the minimum set of kernel-object kinds Phase A requires: `Task` (placeholder for what the scheduler will run), `Endpoint` (rendezvous target for A4), `Notification` (signal target for A4). `MemoryRegion` is deferred to Phase B, where the MMU work makes it concrete. + +## Decision drivers + +- **Consistency with the capability table.** The capability subsystem already uses a per-table index-based arena with generation-tagged handles and zero `unsafe`. Using the same shape for kernel objects means one audited pattern instead of two, and the audit cost of "does this storage keep its invariants?" is paid once. +- **Bounded storage, no heap.** The kernel still has no allocator. Kernel-object arenas must be fixed-size arrays inside the kernel crate. +- **Use-after-destroy prevention.** A handle whose object has been destroyed must fail lookup cleanly. The representation must detect staleness structurally, not through programmer discipline. +- **Typed handles over untyped indices.** A `TaskHandle` must not be passable where an `EndpointHandle` is expected. The type system makes this cheap; a raw index handle does not. +- **Per-type cost accounting.** Different kernel-object kinds have different sizes (`Task` carries scheduler state and eventually a register frame; `Endpoint` has an IPC queue; `Notification` is a single saturating word). A shared arena pays the cost of the largest variant for every slot — a per-type arena grows each kind independently. +- **Deterministic lifecycle.** Capabilities are transferable; many capabilities may name the same object across tasks. Tying object destruction to "last capability drop" requires reference counting and revocation-aware decrement — non-trivial, and a footgun under concurrent revocation. v1 can do better with **explicit destruction** through a kernel-internal destroy path, deferring ref-counting to a later ADR if a real use-case forces it. +- **Global ownership.** A single kernel object is reachable from many capabilities in many tasks. Arenas therefore belong to the kernel, not to a specific task's state. Single-core v1 needs no synchronization around them. +- **No `unsafe`.** Following [ADR-0014](0014-capability-representation.md)'s precedent: a subsystem this security-sensitive should stay in safe Rust wherever possible. Any `unsafe` goes through [`unsafe-policy.md`](../standards/unsafe-policy.md) audit. +- **Room to grow.** `MemoryRegion` lands in Phase B; future kinds (IrqCap, TaskCap-with-address-space, TEE-session) land in their respective phases. The representation must accept new kinds without reshaping the existing ones. + +## Considered options + +### Option A — Shared arena of `KernelObject` enum values + +One global array of `Slot` where `KernelObject` is an enum with a variant per kind. Single storage location, single `ObjectHandle` type indexing into it. + +### Option B — Per-type fixed-size-block arenas with generation-tagged typed handles (chosen) + +One global arena per kernel-object kind — `TaskArena`, `EndpointArena`, `NotificationArena` — each a fixed-size array of `Slot` with the same generation-tagged-handle pattern as [`CapabilityTable`](../../kernel/src/cap/table.rs). Each kind has its own typed handle (`TaskHandle`, `EndpointHandle`, …), so the compiler enforces that a `TaskHandle` cannot be passed where an `EndpointHandle` is expected. The `CapObject` enum wraps whichever handle its `CapKind` dictates. + +### Option C — Heap-allocated per-type linked lists (intrusive) + +Each kernel object carries `prev` / `next` pointers; the kernel maintains a per-kind head pointer. Flexible, textbook — but requires a heap or custom allocator the kernel does not have, and exhibits poor cache behaviour on iteration. + +### Option D — Slab-ish split: raw byte arena + typed capability wrappers + +A "slab" of raw bytes the kernel carves into kind-specific structures on demand, with the capability system handling the typed view separately. Powerful — allows retyping — but complex, introduces indirection, and brings seL4's untyped-memory design burden for no Phase A benefit. + +### Option E — Generation-tagged shared arena with an external type tag + +Like Option A, but the `Slot` stores a type discriminator separately from the inner union, avoiding the "size of the largest variant" penalty by using untyped storage plus per-kind reinterpretation. Closer to Option D in spirit; still opens type-confusion paths that safe Rust would disallow. + +## Decision outcome + +**Chosen: Option B — per-type fixed-size-block arenas with generation-tagged typed handles, global ownership, and explicit-destruction lifecycle.** + +The decision follows directly from the drivers. Option B is **the same shape as [`CapabilityTable`](../../kernel/src/cap/table.rs), instantiated three times**: `TaskArena`, `EndpointArena`, `NotificationArena`. One audited pattern, three instances. Per-type cost accounting falls out of the design. Typed handles prevent whole classes of misuse at compile time. Lifecycle is explicit (destroy via a kernel path); reference counting is deferred to a later ADR if and when a concrete use-case demands it. + +### Core types (sketch) + +```rust +/// Compile-time bound per kernel-object kind. Conservatively small for +/// v1; revisit when a real use-case demands more. +pub const TASK_ARENA_CAPACITY: usize = 16; +pub const ENDPOINT_ARENA_CAPACITY: usize = 16; +pub const NOTIFICATION_ARENA_CAPACITY: usize = 16; + +/// Typed handles. Each one is specifically not interchangeable with the +/// others; the compiler forbids passing a `TaskHandle` where an +/// `EndpointHandle` is expected. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct TaskHandle { index: u16, generation: u32 } + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct EndpointHandle { index: u16, generation: u32 } + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct NotificationHandle { index: u16, generation: u32 } + +/// Task v1 — placeholder fields; scheduler state lands in A5. +pub struct Task { + id: u32, + // scheduler & context fields arrive in A5 +} + +/// Endpoint v1 — IPC queue fields present but unwired. +pub struct Endpoint { + // waiter queues land in A4 +} + +/// Notification v1 — a saturating bit-word target. +pub struct Notification { + word: u64, + // waiter list lands in A4 +} + +/// Errors from kernel-object operations. `#[non_exhaustive]` so new +/// variants (introduced as kinds land) are not breaking changes. +#[non_exhaustive] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ObjError { + /// The arena of this kind is full. + ArenaFull, + /// The handle is stale (object destroyed or slot reused). + InvalidHandle, + /// Destruction refused because at least one capability still names + /// the object. The caller must cap_revoke the subtree or cap_drop + /// every copy first. + StillReachable, +} +``` + +### `CapObject` rewiring + +`CapObject` transitions from an opaque `u64` to a typed enum paralleling `CapKind`: + +```rust +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum CapObject { + Task(TaskHandle), + Endpoint(EndpointHandle), + Notification(NotificationHandle), + // MemoryRegion arrives in Phase B +} + +impl CapObject { + pub const fn kind(self) -> CapKind { /* pattern-match */ } +} +``` + +The outer API of [`CapabilityTable`](../../kernel/src/cap/table.rs) does not change — [ADR-0014](0014-capability-representation.md) reserved room for this migration, and it is delivered here additively. + +### Lifecycle + +- **Creation.** `create_task(args) -> Result` — allocates a slot, fills it, returns the handle. A companion kernel path installs the initial capability in the creator's `CapabilityTable`. +- **Destruction.** `destroy_task(handle) -> Result` (symmetric for `Endpoint` and `Notification`): + - Frees the slot (bumps the generation, returns it to the arena's free list) and returns the stored value. All stale handles fail their generation check on next lookup. + - **Reachability is caller-managed.** The destroy functions do not walk capability tables. Callers that need the reachability invariant check via [`CapabilityTable::references_object`](../../kernel/src/cap/table.rs) before calling destroy, and return `ObjError::StillReachable` to their own callers if any table still names the handle. v1's reachability check is an acceptable O(n) scan at Phase A's scale; a per-object refcount replaces it in a later ADR if measurement demands it. +- **No drop-on-last-cap-drop in v1.** Capability drops / revocations do not reach into kernel-object arenas; the arenas are separately managed. A future ADR may couple them, but v1 keeps the two concerns distinct. + +### Global state + +Per-type arenas are kernel-global singletons (conceptually; the actual kernel struct that owns them is a parameter passed through the call stack so that host tests can construct an isolated instance). Single-core v1 accesses them without synchronization. + +### Why Option B over alternatives + +- **Over Option A (shared enum arena):** avoids "size of largest variant" tax. More importantly, keeps typed handles at compile-time level; Option A erases the kind into an enum tag that the compiler does not check at handle use. +- **Over Option C (intrusive linked list):** the kernel has no heap yet and will not have one for months. Arena-based bounded storage matches current capability in both senses. +- **Over Option D (slab + capability wrappers):** slab storage is strictly more powerful but strictly more complex. seL4's untyped-memory model gives retyping freedom at the cost of an entire security story around untyped-to-typed transitions. Umbrix does not need retyping in v1 and would pay complexity for an unused feature. +- **Over Option E (shared arena + external type tag):** trades the type-safety Option B gets from typed handles for a runtime discriminator. If the discriminator is ever wrong, the caller reinterprets bytes as the wrong type. Option B's compile-time separation is strictly safer at equivalent space cost. + +## Consequences + +### Positive + +- **One audited pattern.** The per-type arena shape is already audited once (in [`CapabilityTable`](../../kernel/src/cap/table.rs)). A3 adds three instances of a known pattern rather than a new one. +- **Typed handles at compile time.** A `TaskHandle` is not substitutable for an `EndpointHandle`. Whole categories of "passed the wrong object" bug become compile errors. +- **Use-after-destroy is structurally impossible.** Generation counter on every arena slot, same as the capability table. +- **Zero `unsafe`.** The arenas are plain safe Rust, following the capability-table model. +- **Deterministic lifecycle.** No reference counting; destruction is an explicit kernel operation with a typed error. +- **Independent per-kind bounds.** Growing `Task` doesn't force `Endpoint` to grow. +- **Cap-wiring is additive.** [ADR-0014](0014-capability-representation.md)'s outer API stays stable; `CapObject` becomes typed without breaking callers. + +### Negative + +- **Compile-time per-kind bounds.** Raising `TASK_ARENA_CAPACITY` requires a rebuild. v1 accepts this — the number lives in one place and is revisited when a real deployment asks for more. No worse than `CAP_TABLE_CAPACITY`. +- **Three near-identical modules.** `task_arena.rs`, `endpoint_arena.rs`, `notification_arena.rs` share shape; three nearly-identical implementations risk drift. Mitigation: factor the arena into a generic `Arena` shared by all three kinds, parameterised over the slot's payload type. This earns the consistency without the copy-paste. +- **Reachability check on destroy is O(n).** v1 scans every live capability to decide whether destruction is safe. At Phase A's scale (one `CapabilityTable` of 64 slots per task, two tasks in the A6 demo) this is trivial. When the system has hundreds of tasks, a per-object reference count replaces the scan — to be decided by a later ADR. +- **No reclamation on capability drop.** A kernel object survives until explicitly destroyed, even if every capability to it is gone. For v1 this is acceptable; a "reclaim on unreachable" operation can be added without changing the outer API. +- **Generic-over-const-generic arena requires one `unsafe`-free pattern we haven't yet written.** Known-solvable in stable Rust; expect one iteration during T-002. + +### Neutral + +- **`MemoryRegion` is absent here.** Phase B lands it when the MMU work does. The representation is additive — a new `CapKind::MemoryRegion` variant and a new arena module, no change to the existing three. +- **Task / Endpoint / Notification are v1 skeletons.** They carry the fields A3 needs and leave room for A4 / A5 additions. Not every field each kind will eventually carry exists at A3 close. +- **Handle size.** `{u16 index, u32 generation}` = 8 bytes (with padding). Same as `CapHandle`; consistency. + +## Pros and cons of the options + +### Option A — Shared `KernelObject` enum arena + +- Pro: one storage location; simpler mental model. +- Pro: one `ObjectHandle` type; uniform capability-to-object lookup. +- Con: every slot is the size of the largest variant — `Task` dominates, so `Endpoint` and `Notification` slots waste space on padding. +- Con: loss of compile-time kind separation; runtime match on every access. +- Con: harder to compute per-kind bounds; exhaustion of one kind starves the others. + +### Option B — Per-type arenas with typed handles (chosen) + +- Pro: mirrors the audited capability-table pattern; one shape, three instances. +- Pro: typed handles are compile-time guarantees. +- Pro: per-kind storage is right-sized. +- Pro: zero `unsafe`, no heap. +- Con: three arenas to initialise, test, and evolve — mitigated by a shared generic arena implementation. +- Con: exhaustion is per-kind — arguably a feature (you know which kind ran out), arguably a cost (you can't borrow a free slot from another kind). + +### Option C — Intrusive linked lists per kind + +- Pro: dynamic size per kind; no compile-time bound. +- Pro: textbook kernel pattern. +- Con: requires a heap or custom allocator the kernel does not have. +- Con: cache-unfriendly iteration. +- Con: pointer soup; harder to audit. + +### Option D — Slab-ish retyping store + +- Pro: maximum flexibility; enables future "untyped memory → typed kernel object" operations without rework. +- Con: massive complexity for v1; introduces an entire sub-story around retyping invariants that Umbrix does not need yet. +- Con: the security model for retyping is non-trivial (seL4's is formally verified; we would not match that in v1). + +### Option E — Shared arena + external type tag + +- Pro: avoids the "size of largest variant" cost of Option A. +- Con: hands safety to a runtime type tag instead of to the type system; a single wrong discriminator produces a type-confusion bug the compiler cannot catch. +- Con: adds complexity without matching Option B's compile-time guarantees. + +## Open questions + +- **Generic arena shape.** Whether the three per-kind arenas are three copies of the pattern or one `Arena` instantiated three times. Preference: the generic — decided during T-002 implementation. +- **Per-object refcount for destruction safety.** v1 uses an O(n) reachability scan. When measurement shows this cost in a realistic workload (probably around F or G), a successor ADR introduces refcounts behind the existing outer API. +- **Task ownership of kernel objects.** Phase B adds the "a Task owns an AddressSpace, which owns MemoryRegion caps" chain. That ADR decides whether an object can be arbitrarily transferred between tasks or is tied to a creator — out of scope for A3. +- **Initial-capability install.** Creating a kernel object produces a capability; *which* `CapabilityTable` that capability lands in (the creator's, a specified target's, or the kernel's boot-task) is a Phase-A detail T-002 picks for v1 and a future ADR formalises if it changes. +- **Cross-core safety.** Single-core v1 uses no atomics or locks. Phase C's multi-core ADR extends this — likely per-CPU arenas with a stealing policy, or a global arena with a spinlock depending on contention. + +## References + +- [ADR-0001 — Capability-based microkernel architecture](0001-microkernel-architecture.md). +- [ADR-0014 — Capability representation](0014-capability-representation.md). +- [ADR-0013 — Roadmap and planning process](0013-roadmap-and-planning.md). +- [architectural-principles.md](../standards/architectural-principles.md) — bounded kernel state, no ambient authority. +- [security-model.md](../architecture/security-model.md) — kernel objects as the unit of authority. +- [`kernel/src/cap/table.rs`](../../kernel/src/cap/table.rs) — existing audited per-type-arena pattern to mirror. +- [T-002 — Kernel object storage foundation](../analysis/tasks/phase-a/T-002-kernel-object-storage.md) — the implementing task. +- seL4 kernel-object model — https://sel4.systems/ (prior art; retyping not adopted here). +- Hubris kernel task/cell storage — https://hubris.oxide.computer/ (direct shape parallel: compile-time-bounded per-type arenas). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a4d5a86..9f8b88d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -44,6 +44,7 @@ Each ADR contains: | 0013 | [Roadmap and planning process](0013-roadmap-and-planning.md) | Accepted | 2026-04-20 | | 0014 | [Capability representation](0014-capability-representation.md) | Accepted | 2026-04-20 | | 0015 | [AI integration stance: userspace-only, kernel-neutral](0015-ai-integration-stance.md) | Accepted | 2026-04-20 | +| 0016 | [Kernel object storage](0016-kernel-object-storage.md) | Accepted | 2026-04-21 | ## Creating a new ADR diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 005e76b..c013b26 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -5,16 +5,17 @@ A short pointer file updated as work progresses. For the full plan see [`phases/ --- - **Active phase:** A — Kernel core on QEMU `virt`. -- **Active milestone:** A2 — Capability table foundation. -- **Active task:** [T-001 — Capability table foundation](../analysis/tasks/phase-a/T-001-capability-table-foundation.md) (status: **In Review** — awaiting PR review from `development` to `main`). +- **Active milestone:** A3 — Kernel objects. +- **Active task:** [T-002 — Kernel object storage foundation](../analysis/tasks/phase-a/T-002-kernel-object-storage.md) (status: **In Review** — implementation landed on `development`, awaiting maintainer PR review). - **Working branch:** `development`. -- **Last completed milestone:** A1 — Bootable skeleton, on 2026-04-20 (commit `2944e7d`). -- **Last review:** none yet; the first business review will accompany A2 completion. -- **Next review trigger:** maintainer PR review → if merged, T-001 moves to `Done` and A2 milestone review follows. +- **Last completed milestone:** A2 — Capability table foundation, on 2026-04-21 (PR #1 merged to `main`). +- **Last completed task:** [T-001 — Capability table foundation](../analysis/tasks/phase-a/T-001-capability-table-foundation.md) — `Done` 2026-04-21. +- **Last review:** [A2 completion business review](../analysis/reviews/business-reviews/2026-04-21-A2-completion.md) — 2026-04-21. +- **Next review trigger:** code + security review of T-002's implementation when it reaches `In Review`; business review waits for A6 per [phase-a.md closure](phases/phase-a.md). ## Notes -- T-001 implementation landed on `development`; see the task file for evidence and test coverage: [T-001](../analysis/tasks/phase-a/T-001-capability-table-foundation.md). -- [ADR-0014](../decisions/0014-capability-representation.md) Accepted; the kernel now exposes `cap::CapabilityTable`, `cap::CapHandle`, `cap::CapRights`, `cap::Capability`, and `cap::CapError`. -- The new module is not yet wired into `run`. +- The capability subsystem (T-001) and kernel-object subsystem (T-002) both shipped with zero `unsafe` and no heap. Neither is wired into `run` yet; that is Phase-A later-milestone work. +- [ADR-0014](../decisions/0014-capability-representation.md) and [ADR-0016](../decisions/0016-kernel-object-storage.md) both Accepted. +- T-002 introduced `obj::{Arena, Task, Endpoint, Notification}` with typed handles and rewired `CapObject` to a typed enum paralleling `CapKind`. `Capability::new` lost its redundant `kind` parameter (kind is now derived from the object's variant). All 77 host tests green. - The maintainer updates this file when the active task changes. AI agents update it when they move a task to `In Progress` or `Done` via the [`start-task`](../../.claude/skills/start-task/SKILL.md) and [`conduct-review`](../../.claude/skills/conduct-review/SKILL.md) skills. diff --git a/docs/roadmap/phases/phase-a.md b/docs/roadmap/phases/phase-a.md index 684bab3..d67f790 100644 --- a/docs/roadmap/phases/phase-a.md +++ b/docs/roadmap/phases/phase-a.md @@ -21,7 +21,7 @@ Kernel boots under QEMU `virt` aarch64 and writes a greeting to the PL011 consol --- -## Milestone A2 — Capability table foundation (active) +## Milestone A2 — Capability table foundation ✓ (done 2026-04-21) Per-task capability table data structure, capability kind enum, and the in-kernel operations (`cap_copy`, `cap_derive`, `cap_revoke`, `cap_drop`) — without IPC integration. Capabilities are move-only Rust tokens; a derivation tree enforces the narrowing-only invariant and supports cascading revocation. @@ -50,8 +50,8 @@ Per-task capability table data structure, capability kind enum, and the in-kerne ### Tasks under A2 -- [T-001 — Capability table foundation](../../analysis/tasks/phase-a/T-001-capability-table-foundation.md) — In Review. -- Subsequent tasks (T-002+) will be opened as T-001 lands if further decomposition is needed; current plan is T-001 covers the milestone in one task. +- [T-001 — Capability table foundation](../../analysis/tasks/phase-a/T-001-capability-table-foundation.md) — Done. +- T-001 covered the milestone in one task as planned; no further decomposition was needed. ### Informs @@ -82,6 +82,10 @@ Introduce the first concrete kernel objects — `Task`, `Endpoint`, `Notificatio - No heap; kernel objects live in a bounded pool per type. - Host tests for lifecycle and handle invalidation pass. +### Tasks under A3 + +- [T-002 — Kernel object storage foundation](../../analysis/tasks/phase-a/T-002-kernel-object-storage.md) — In Review. + ### Informs Milestone A4 builds the actual IPC paths against the `Endpoint` and `Notification` objects introduced here. diff --git a/kernel/src/cap/mod.rs b/kernel/src/cap/mod.rs index a723b7f..21d6ff4 100644 --- a/kernel/src/cap/mod.rs +++ b/kernel/src/cap/mod.rs @@ -11,21 +11,24 @@ //! [`security-model.md`][sec] and [architectural principle P1][p1]. //! //! [adr-0014]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0014-capability-representation.md +//! [adr-0016]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0016-kernel-object-storage.md //! [sec]: https://github.com/cemililik/UmbrixOS/blob/main/docs/architecture/security-model.md //! [p1]: https://github.com/cemililik/UmbrixOS/blob/main/docs/standards/architectural-principles.md#p1--no-ambient-authority //! -//! ## Status (v1, T-001) +//! ## Status (T-001 + T-002) //! //! - [`Capability`] is move-only (not `Copy`, not `Clone`). //! - [`CapRights`] carries four v1 rights (`DUPLICATE`, `DERIVE`, `REVOKE`, //! `TRANSFER`); more rights land with their subsystems. -//! - [`CapObject`] is a placeholder for the kernel object the capability -//! points at; Milestone A3 replaces it with a typed reference. +//! - [`CapObject`] is a typed enum that names a kernel object by its +//! typed handle — [`super::obj::TaskHandle`] / [`super::obj::EndpointHandle`] +//! / [`super::obj::NotificationHandle`] — following [ADR-0016][adr-0016]. +//! `MemoryRegion` arrives in Phase B. //! - [`CapabilityTable`] implements //! [`cap_copy`][CapabilityTable::cap_copy], //! [`cap_derive`][CapabilityTable::cap_derive], //! [`cap_revoke`][CapabilityTable::cap_revoke], and -//! [`cap_drop`][CapabilityTable::cap_drop] with `zero` `unsafe`. +//! [`cap_drop`][CapabilityTable::cap_drop] with zero `unsafe`. //! //! What v1 deliberately omits: IPC integration, multi-core safety, //! persistent capabilities, badge schemes. Each has a named open question @@ -37,12 +40,13 @@ mod table; pub use rights::CapRights; pub use table::{CapHandle, CapabilityTable, CAP_TABLE_CAPACITY, MAX_DERIVATION_DEPTH}; +use crate::obj::{EndpointHandle, NotificationHandle, TaskHandle}; + /// Kinds of kernel object a capability can refer to. /// -/// The variants are placeholders in v1: they discriminate the capability -/// by *kind* but the actual kernel-object references are encoded as -/// opaque [`CapObject`] values until Milestone A3 introduces real -/// kernel-object types. +/// The discriminator for a capability's [`CapObject`]; `CapObject` +/// carries the actual typed handle. `MemoryRegion` is reserved here but +/// has no `CapObject` variant until Phase B introduces the MMU. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum CapKind { /// Refers to a task kernel object. @@ -51,33 +55,36 @@ pub enum CapKind { Endpoint, /// Refers to an asynchronous notification kernel object. Notification, - /// Refers to a physical memory region. + /// Refers to a physical memory region (Phase B). MemoryRegion, } -/// Opaque reference to a kernel object. +/// Typed reference to a kernel object. /// -/// In v1 this wraps a plain `u64` identifier whose interpretation is left -/// to the caller (kernel-internal). Milestone A3 replaces `CapObject` with -/// a typed reference; the outer API of the capability table does not -/// change. The wrapped value is kept private and read through -/// [`CapObject::raw`] so that every construction or inspection site goes -/// through an auditable function — a small safeguard for when the typed -/// replacement lands. +/// Each variant carries the [typed handle][crate::obj] of its kind, so +/// passing a `TaskHandle` where an `EndpointHandle` is expected is a +/// compile-time error. The discriminator matches [`CapKind`] one-to-one. +/// `MemoryRegion` is deferred to Phase B; a capability with that kind +/// cannot be constructed in v1. #[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub struct CapObject(u64); +pub enum CapObject { + /// Capability naming a [`Task`][crate::obj::Task] kernel object. + Task(TaskHandle), + /// Capability naming an [`Endpoint`][crate::obj::Endpoint] kernel object. + Endpoint(EndpointHandle), + /// Capability naming a [`Notification`][crate::obj::Notification] kernel object. + Notification(NotificationHandle), +} impl CapObject { - /// Construct a capability-object reference from its raw identifier. + /// Return the [`CapKind`] discriminator matching this object. #[must_use] - pub const fn new(id: u64) -> Self { - Self(id) - } - - /// Return the raw identifier this `CapObject` wraps. - #[must_use] - pub const fn raw(self) -> u64 { - self.0 + pub const fn kind(self) -> CapKind { + match self { + Self::Task(_) => CapKind::Task, + Self::Endpoint(_) => CapKind::Endpoint, + Self::Notification(_) => CapKind::Notification, + } } } @@ -89,30 +96,26 @@ impl CapObject { /// Rust type system enforces the move-only discipline by construction. /// /// `Debug` is derived so that test assertions can format capabilities; -/// the derived impl does not expose any unforgeable bits because -/// [`CapObject`] is an opaque `u64` in v1. +/// the derived impl exposes typed handles but no other unforgeable bits. #[derive(Debug)] pub struct Capability { - kind: CapKind, rights: CapRights, object: CapObject, } impl Capability { - /// Construct a capability with the given kind, rights, and object. + /// Construct a capability with the given rights over `object`. The + /// [`CapKind`] is derived from the `object`'s variant, so + /// kind-and-object cannot disagree by construction. #[must_use] - pub const fn new(kind: CapKind, rights: CapRights, object: CapObject) -> Self { - Self { - kind, - rights, - object, - } + pub const fn new(rights: CapRights, object: CapObject) -> Self { + Self { rights, object } } - /// Return the capability's kind. + /// Return the capability's kind, derived from its object variant. #[must_use] pub const fn kind(&self) -> CapKind { - self.kind + self.object.kind() } /// Return the capability's rights. @@ -121,7 +124,7 @@ impl Capability { self.rights } - /// Return the capability's opaque object reference. + /// Return the capability's typed object reference. #[must_use] pub const fn object(&self) -> CapObject { self.object diff --git a/kernel/src/cap/table.rs b/kernel/src/cap/table.rs index 953a98f..811f483 100644 --- a/kernel/src/cap/table.rs +++ b/kernel/src/cap/table.rs @@ -8,7 +8,7 @@ //! //! [adr-0014]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0014-capability-representation.md -use super::{CapError, CapRights, Capability}; +use super::{CapError, CapObject, CapRights, Capability}; /// Maximum number of capabilities in a single table. /// @@ -169,11 +169,12 @@ impl CapabilityTable { src: CapHandle, new_rights: CapRights, ) -> Result { - // Snapshot the source. - let (kind, rights, object, parent, depth) = { + // Snapshot the source. `kind` is no longer a separate field on + // `Capability` (ADR-0016 put it on `CapObject`), so we read + // rights / object / tree-link fields directly. + let (rights, object, parent, depth) = { let entry = self.entry_of(src)?; ( - entry.capability.kind(), entry.capability.rights(), entry.capability.object(), entry.parent, @@ -204,7 +205,7 @@ impl CapabilityTable { }; self.slots[new_index as usize].entry = Some(SlotEntry { - capability: Capability::new(kind, new_rights, object), + capability: Capability::new(new_rights, object), parent, first_child: None, next_sibling: former_first_child, @@ -225,9 +226,9 @@ impl CapabilityTable { } /// Install a child capability — one whose parent is `src`. Typically - /// used when narrowing the capability's *scope*; v1 passes scope - /// narrowing through the opaque `new_object` since kernel objects do - /// not yet carry typed scope. + /// used when narrowing the capability's *scope*: the caller supplies + /// narrowed rights and a `new_object` identifying the target kernel + /// object. /// /// # Errors /// @@ -248,14 +249,12 @@ impl CapabilityTable { // `entry_of` already validates the handle and resolves to a live // slot; its input handle gives us the index we need for the new // entry's `parent` link without a second `resolve_handle` call. - let (kind, rights, parent_index, parent_depth) = { + // `kind` moved onto `CapObject` in ADR-0016, so we no longer + // snapshot it here — the new entry's kind is carried by + // `new_object`'s variant. + let (rights, parent_index, parent_depth) = { let entry = self.entry_of(src)?; - ( - entry.capability.kind(), - entry.capability.rights(), - src.index, - entry.depth, - ) + (entry.capability.rights(), src.index, entry.depth) }; if !rights.contains(CapRights::DERIVE) { @@ -288,7 +287,7 @@ impl CapabilityTable { }; self.slots[new_index as usize].entry = Some(SlotEntry { - capability: Capability::new(kind, new_rights, new_object), + capability: Capability::new(new_rights, new_object), parent: Some(parent_index), first_child: None, next_sibling: former_first_child, @@ -440,6 +439,24 @@ impl CapabilityTable { Ok(&entry.capability) } + /// Return `true` if any live capability in this table names the + /// given kernel object. Used by the [`crate::obj`] destroy paths + /// to implement the reachability check described in + /// [ADR-0016][adr-0016] — callers pass their watcher tables and + /// refuse destruction if any of them reports a reference. + /// + /// The check is linear in [`CAP_TABLE_CAPACITY`]; acceptable at + /// Phase A's scale. + /// + /// [adr-0016]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0016-kernel-object-storage.md + #[must_use] + pub fn references_object(&self, target: CapObject) -> bool { + self.slots + .iter() + .filter_map(|s| s.entry.as_ref()) + .any(|entry| entry.capability.object() == target) + } + // ----- internals ----- /// Validate a handle and return the underlying slot index. @@ -548,14 +565,22 @@ impl CapabilityTable { mod tests { use super::{CapHandle, CapabilityTable, CAP_TABLE_CAPACITY, MAX_DERIVATION_DEPTH}; use crate::cap::{CapError, CapKind, CapObject, CapRights, Capability}; + use crate::obj::TaskHandle; /// All v1 rights set. fn all_rights() -> CapRights { CapRights::DUPLICATE | CapRights::DERIVE | CapRights::REVOKE | CapRights::TRANSFER } + /// Build a task-kind `CapObject` with a synthesized handle — for + /// tests that need distinct capability values without allocating + /// through a real arena. + fn task_object(tag: u16) -> CapObject { + CapObject::Task(TaskHandle::test_handle(tag, 0)) + } + fn root_cap() -> Capability { - Capability::new(CapKind::Task, all_rights(), CapObject::new(0xAA)) + Capability::new(all_rights(), task_object(0xAA)) } #[test] @@ -565,7 +590,7 @@ mod tests { let cap = t.lookup(h).unwrap(); assert_eq!(cap.kind(), CapKind::Task); assert_eq!(cap.rights(), all_rights()); - assert_eq!(cap.object(), CapObject::new(0xAA)); + assert_eq!(cap.object(), task_object(0xAA)); } #[test] @@ -621,7 +646,7 @@ mod tests { #[test] fn cap_copy_rejects_widened_rights() { let mut t = CapabilityTable::new(); - let narrow = Capability::new(CapKind::Task, CapRights::DUPLICATE, CapObject::new(0)); + let narrow = Capability::new(CapRights::DUPLICATE, task_object(0)); let src = t.insert_root(narrow).unwrap(); let wider = CapRights::DUPLICATE | CapRights::REVOKE; assert_eq!(t.cap_copy(src, wider).unwrap_err(), CapError::WidenedRights); @@ -630,7 +655,7 @@ mod tests { #[test] fn cap_copy_without_duplicate_right_fails() { let mut t = CapabilityTable::new(); - let no_dup = Capability::new(CapKind::Task, CapRights::DERIVE, CapObject::new(0)); + let no_dup = Capability::new(CapRights::DERIVE, task_object(0)); let src = t.insert_root(no_dup).unwrap(); assert_eq!( t.cap_copy(src, CapRights::EMPTY).unwrap_err(), @@ -643,21 +668,19 @@ mod tests { let mut t = CapabilityTable::new(); let src = t.insert_root(root_cap()).unwrap(); let child_rights = CapRights::DUPLICATE; - let child = t - .cap_derive(src, child_rights, CapObject::new(0xBB)) - .unwrap(); + let child = t.cap_derive(src, child_rights, task_object(0xBB)).unwrap(); let child_cap = t.lookup(child).unwrap(); assert_eq!(child_cap.rights(), child_rights); - assert_eq!(child_cap.object(), CapObject::new(0xBB)); + assert_eq!(child_cap.object(), task_object(0xBB)); } #[test] fn cap_derive_without_derive_right_fails() { let mut t = CapabilityTable::new(); - let no_derive = Capability::new(CapKind::Task, CapRights::DUPLICATE, CapObject::new(0)); + let no_derive = Capability::new(CapRights::DUPLICATE, task_object(0)); let src = t.insert_root(no_derive).unwrap(); assert_eq!( - t.cap_derive(src, CapRights::EMPTY, CapObject::new(0)) + t.cap_derive(src, CapRights::EMPTY, task_object(0)) .unwrap_err(), CapError::InsufficientRights ); @@ -666,11 +689,11 @@ mod tests { #[test] fn cap_derive_rejects_widened_rights() { let mut t = CapabilityTable::new(); - let narrow = Capability::new(CapKind::Task, CapRights::DERIVE, CapObject::new(0)); + let narrow = Capability::new(CapRights::DERIVE, task_object(0)); let src = t.insert_root(narrow).unwrap(); let wider = CapRights::DERIVE | CapRights::REVOKE; assert_eq!( - t.cap_derive(src, wider, CapObject::new(0)).unwrap_err(), + t.cap_derive(src, wider, task_object(0)).unwrap_err(), CapError::WidenedRights ); } @@ -682,12 +705,10 @@ mod tests { // Build MAX_DERIVATION_DEPTH-deep chain (each child gets DERIVE so we // can go again); the next derive should fail. for _ in 0..MAX_DERIVATION_DEPTH { - current = t - .cap_derive(current, all_rights(), CapObject::new(0)) - .unwrap(); + current = t.cap_derive(current, all_rights(), task_object(0)).unwrap(); } assert_eq!( - t.cap_derive(current, all_rights(), CapObject::new(0)) + t.cap_derive(current, all_rights(), task_object(0)) .unwrap_err(), CapError::DerivationTooDeep ); @@ -697,7 +718,7 @@ mod tests { fn cap_revoke_removes_only_descendants() { let mut t = CapabilityTable::new(); let src = t.insert_root(root_cap()).unwrap(); - let child = t.cap_derive(src, all_rights(), CapObject::new(1)).unwrap(); + let child = t.cap_derive(src, all_rights(), task_object(1)).unwrap(); t.cap_revoke(src).unwrap(); @@ -713,13 +734,9 @@ mod tests { fn cap_revoke_cascades_depth_three() { let mut t = CapabilityTable::new(); let root = t.insert_root(root_cap()).unwrap(); - let child = t.cap_derive(root, all_rights(), CapObject::new(1)).unwrap(); - let grand = t - .cap_derive(child, all_rights(), CapObject::new(2)) - .unwrap(); - let great = t - .cap_derive(grand, all_rights(), CapObject::new(3)) - .unwrap(); + let child = t.cap_derive(root, all_rights(), task_object(1)).unwrap(); + let grand = t.cap_derive(child, all_rights(), task_object(2)).unwrap(); + let great = t.cap_derive(grand, all_rights(), task_object(3)).unwrap(); t.cap_revoke(root).unwrap(); @@ -730,13 +747,52 @@ mod tests { } #[test] - fn cap_revoke_without_revoke_right_fails() { + fn references_object_sees_live_caps_only() { + // `references_object` supports the ADR-0016 reachability check. + // It returns true iff some live capability names the target. + let mut t = CapabilityTable::new(); + let target = task_object(0xCC); + let other = task_object(0xDD); + assert!( + !t.references_object(target), + "empty table references nothing" + ); + + let h = t + .insert_root(Capability::new(all_rights(), target)) + .unwrap(); + assert!(t.references_object(target)); + assert!(!t.references_object(other)); + + // Cap-drop removes the reference. + t.cap_drop(h).unwrap(); + assert!(!t.references_object(target)); + } + + #[test] + fn cap_revoke_clears_references_object() { + // After cap_revoke, references_object must return false for the + // objects named only by the revoked descendants. let mut t = CapabilityTable::new(); - let no_revoke = Capability::new( - CapKind::Task, - CapRights::DUPLICATE | CapRights::DERIVE, - CapObject::new(0), + let target = task_object(0xEE); + let root = t + .insert_root(Capability::new(all_rights(), task_object(0xAA))) + .unwrap(); + let child = t.cap_derive(root, all_rights(), target).unwrap(); + + assert!(t.references_object(target), "child names the target"); + t.cap_revoke(root).unwrap(); + assert!( + !t.references_object(target), + "revoke must clear the child's reference" ); + assert_eq!(t.lookup(child).unwrap_err(), CapError::InvalidHandle); + } + + #[test] + fn cap_revoke_without_revoke_right_fails() { + let mut t = CapabilityTable::new(); + let no_revoke = Capability::new(CapRights::DUPLICATE | CapRights::DERIVE, task_object(0)); let src = t.insert_root(no_revoke).unwrap(); assert_eq!(t.cap_revoke(src).unwrap_err(), CapError::InsufficientRights); } @@ -761,7 +817,7 @@ mod tests { fn copy_of_a_child_shares_parent() { let mut t = CapabilityTable::new(); let root = t.insert_root(root_cap()).unwrap(); - let child = t.cap_derive(root, all_rights(), CapObject::new(1)).unwrap(); + let child = t.cap_derive(root, all_rights(), task_object(1)).unwrap(); let peer = t.cap_copy(child, all_rights()).unwrap(); // Revoking `root` must invalidate both `child` and `peer` — they @@ -805,9 +861,7 @@ mod tests { // refuse rather than orphan the child. let mut t = CapabilityTable::new(); let parent = t.insert_root(root_cap()).unwrap(); - let _child = t - .cap_derive(parent, all_rights(), CapObject::new(1)) - .unwrap(); + let _child = t.cap_derive(parent, all_rights(), task_object(1)).unwrap(); assert_eq!( t.cap_drop(parent).unwrap_err(), @@ -826,9 +880,9 @@ mod tests { // outer two must remain reachable. let mut t = CapabilityTable::new(); let root = t.insert_root(root_cap()).unwrap(); - let a = t.cap_derive(root, all_rights(), CapObject::new(1)).unwrap(); - let b = t.cap_derive(root, all_rights(), CapObject::new(2)).unwrap(); - let c = t.cap_derive(root, all_rights(), CapObject::new(3)).unwrap(); + let a = t.cap_derive(root, all_rights(), task_object(1)).unwrap(); + let b = t.cap_derive(root, all_rights(), task_object(2)).unwrap(); + let c = t.cap_derive(root, all_rights(), task_object(3)).unwrap(); t.cap_drop(b).unwrap(); assert!(t.lookup(a).is_ok()); assert!(t.lookup(c).is_ok()); diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index 1f120e3..9a7055b 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -18,10 +18,13 @@ //! //! ## Subsystems //! +//! - [`obj`] — kernel-object subsystem (Phase A3 / [T-002]): per-type +//! arenas holding the concrete entities that capabilities name. //! - [`cap`] — capability subsystem (Phase A2 / [T-001]), the substrate every //! later subsystem refers through for authority. //! //! [T-001]: https://github.com/cemililik/UmbrixOS/blob/main/docs/analysis/tasks/phase-a/T-001-capability-table-foundation.md +//! [T-002]: https://github.com/cemililik/UmbrixOS/blob/main/docs/analysis/tasks/phase-a/T-002-kernel-object-storage.md #![cfg_attr(not(test), no_std)] // Kernel-specific stricter lints on top of the workspace set. @@ -34,6 +37,7 @@ #![deny(clippy::float_arithmetic)] pub mod cap; +pub mod obj; use umbrix_hal::Console; diff --git a/kernel/src/obj/arena.rs b/kernel/src/obj/arena.rs new file mode 100644 index 0000000..5b37c9d --- /dev/null +++ b/kernel/src/obj/arena.rs @@ -0,0 +1,260 @@ +//! Generic bounded arena for kernel-object storage. +//! +//! `Arena` is a fixed-size array of slots, each either empty or +//! holding a `T`. Slot identity is captured by a [`SlotId`] — a pair of +//! `(index, generation)` that survives slot reuse by matching both +//! parts; a stale id fails lookup even if the underlying slot has been +//! refilled. +//! +//! Shape and rationale: [ADR-0016][adr-0016]. The pattern mirrors the +//! [`CapabilityTable`][`crate::cap::CapabilityTable`] from A2 — one +//! audited arena shape, now generic, instantiated three times in the +//! per-kind kernel-object modules. +//! +//! [adr-0016]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0016-kernel-object-storage.md + +/// Index into an [`Arena`]'s backing array. +type Index = u16; + +/// Generation counter; bumped on every free to make stale ids detectable. +type Generation = u32; + +/// Identifier of a slot within an arena. +/// +/// A `SlotId` is valid as long as the slot it names still holds the +/// value that was allocated with that id. Once the slot is freed and +/// reused, the slot's generation advances and the old id fails lookup. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct SlotId { + index: Index, + generation: Generation, +} + +impl SlotId { + /// Raw index component. Crate-internal; exposed for tests. + #[cfg(test)] + #[must_use] + pub(crate) const fn index(self) -> Index { + self.index + } + + /// Raw generation component. Crate-internal; exposed for tests. + #[cfg(test)] + #[must_use] + pub(crate) const fn generation(self) -> Generation { + self.generation + } + + /// Construct a `SlotId` from parts. Crate-internal; production code + /// obtains `SlotId`s only from [`Arena::allocate`]. Exposed for + /// unit-test scaffolding in sibling modules. + #[cfg(test)] + #[must_use] + pub(crate) const fn from_parts(index: Index, generation: Generation) -> Self { + Self { index, generation } + } +} + +/// One storage cell of an [`Arena`]. Either populated or participating +/// in the free list. +struct Slot { + entry: Option, + generation: Generation, + next_free: Option, +} + +/// Fixed-capacity, heap-free, generation-tagged arena. +/// +/// `N` is the compile-time capacity. The arena never allocates; free +/// slots form an embedded linked list threaded through `next_free`. +pub struct Arena { + slots: [Slot; N], + free_head: Option, +} + +impl Default for Arena { + fn default() -> Self { + Self::new() + } +} + +impl Arena { + /// Construct an empty arena with every slot threaded into the free + /// list. The first allocation returns index `0`. + /// + /// Invariant: `N <= u16::MAX` so that every slot index fits in the + /// [`Index`] type. The `const` assertion inside the body catches + /// violation at compile time. + #[must_use] + pub fn new() -> Self { + const { + assert!( + N <= Index::MAX as usize, + "arena capacity exceeds Index::MAX" + ); + } + + let slots: [Slot; N] = core::array::from_fn(|i| { + let next = i.wrapping_add(1); + let next_free = if next < N { + // Bounded by `N <= Index::MAX`; checked by the const + // assertion above. + #[allow( + clippy::cast_possible_truncation, + reason = "bounded by N <= Index::MAX" + )] + Some(next as Index) + } else { + None + }; + Slot { + entry: None, + generation: 0, + next_free, + } + }); + + Self { + slots, + free_head: if N > 0 { Some(0) } else { None }, + } + } + + /// Allocate a new slot, storing `value` in it. Returns a [`SlotId`] + /// that refers to the allocation until it is freed. + /// + /// Returns `None` when every slot is in use. + pub fn allocate(&mut self, value: T) -> Option { + let head = self.free_head?; + debug_assert!((head as usize) < N, "free_head out of bounds"); + let slot = self.slots.get_mut(head as usize)?; + let next_free = slot.next_free; + slot.entry = Some(value); + slot.next_free = None; + self.free_head = next_free; + Some(SlotId { + index: head, + generation: slot.generation, + }) + } + + /// Free the slot named by `id`, returning the stored value. + /// + /// Returns `None` if the id is stale (generation mismatch) or + /// points at an already-free slot. + pub fn free(&mut self, id: SlotId) -> Option { + let slot = self.slots.get_mut(id.index as usize)?; + if slot.generation != id.generation { + return None; + } + let value = slot.entry.take()?; + slot.generation = slot.generation.wrapping_add(1); + slot.next_free = self.free_head; + self.free_head = Some(id.index); + Some(value) + } + + /// Return a reference to the value at `id`, or `None` if stale / + /// freed. + #[must_use] + pub fn get(&self, id: SlotId) -> Option<&T> { + let slot = self.slots.get(id.index as usize)?; + if slot.generation != id.generation { + return None; + } + slot.entry.as_ref() + } + + /// Return a mutable reference to the value at `id`, or `None` if + /// stale / freed. + pub fn get_mut(&mut self, id: SlotId) -> Option<&mut T> { + let slot = self.slots.get_mut(id.index as usize)?; + if slot.generation != id.generation { + return None; + } + slot.entry.as_mut() + } + + /// Return `true` when `id` still names a live slot. + #[must_use] + pub fn contains(&self, id: SlotId) -> bool { + self.get(id).is_some() + } +} + +#[cfg(test)] +#[allow( + clippy::arithmetic_side_effects, + clippy::unwrap_used, + clippy::expect_used, + reason = "tests may use pragmas forbidden in production kernel code" +)] +mod tests { + use super::Arena; + + #[test] + fn allocate_and_get_round_trip() { + let mut arena: Arena = Arena::new(); + let id = arena.allocate(42).unwrap(); + assert_eq!(arena.get(id), Some(&42)); + assert!(arena.contains(id)); + } + + #[test] + fn free_invalidates_id() { + let mut arena: Arena = Arena::new(); + let id = arena.allocate(7).unwrap(); + assert_eq!(arena.free(id), Some(7)); + assert_eq!(arena.get(id), None); + assert!(!arena.contains(id)); + } + + #[test] + fn free_then_allocate_bumps_generation() { + let mut arena: Arena = Arena::new(); + let first = arena.allocate(1).unwrap(); + arena.free(first).unwrap(); + let second = arena.allocate(2).unwrap(); + assert_eq!(first.index(), second.index(), "slot reuse expected"); + assert_ne!(first.generation(), second.generation()); + assert_eq!(arena.get(first), None, "stale id must fail"); + assert_eq!(arena.get(second), Some(&2)); + } + + #[test] + fn exhaustion_returns_none() { + let mut arena: Arena = Arena::new(); + let _a = arena.allocate(1).unwrap(); + let _b = arena.allocate(2).unwrap(); + assert!(arena.allocate(3).is_none()); + } + + #[test] + fn free_middle_then_allocate_reuses_that_slot() { + let mut arena: Arena = Arena::new(); + let a = arena.allocate(10).unwrap(); + let b = arena.allocate(20).unwrap(); + let c = arena.allocate(30).unwrap(); + arena.free(b).unwrap(); + let d = arena.allocate(99).unwrap(); + assert_eq!(d.index(), b.index(), "b's slot was reused"); + assert_eq!(arena.get(a), Some(&10)); + assert_eq!(arena.get(c), Some(&30)); + assert_eq!(arena.get(d), Some(&99)); + assert_eq!(arena.get(b), None); + } + + #[test] + fn get_mut_permits_mutation() { + let mut arena: Arena = Arena::new(); + let id = arena.allocate(1).unwrap(); + *arena.get_mut(id).unwrap() = 2; + assert_eq!(arena.get(id), Some(&2)); + } + + #[test] + fn empty_capacity_arena_has_no_free_slot() { + let mut arena: Arena = Arena::new(); + assert!(arena.allocate(0).is_none()); + } +} diff --git a/kernel/src/obj/endpoint.rs b/kernel/src/obj/endpoint.rs new file mode 100644 index 0000000..5f3a982 --- /dev/null +++ b/kernel/src/obj/endpoint.rs @@ -0,0 +1,113 @@ +//! `Endpoint` kernel object — v1 skeleton for synchronous-rendezvous IPC. +//! +//! Per [ADR-0016][adr-0016], v1 stores endpoints in a per-type +//! [`Arena`][super::arena::Arena] with a typed [`EndpointHandle`]. The +//! IPC wait/wake queues are reserved here as zero-sized placeholders; +//! Milestone A4 populates them with real waiter lists when `send` / +//! `recv` / `reply_recv` arrive. +//! +//! [adr-0016]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0016-kernel-object-storage.md + +use super::arena::{Arena, SlotId}; +use super::{ObjError, ENDPOINT_ARENA_CAPACITY}; + +/// The v1 `Endpoint` kernel object — an IPC rendezvous point. +/// +/// The waiter queues are added in A4. v1 carries only an identifier so +/// tests can distinguish endpoints during creation / destruction flows. +#[derive(Debug)] +pub struct Endpoint { + id: u32, +} + +impl Endpoint { + /// Construct an endpoint with the given identifier. + #[must_use] + pub const fn new(id: u32) -> Self { + Self { id } + } + + /// Return the endpoint's identifier. + #[must_use] + pub const fn id(&self) -> u32 { + self.id + } +} + +/// Typed handle referring to an endpoint in an [`EndpointArena`]. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct EndpointHandle(SlotId); + +impl EndpointHandle { + pub(crate) const fn from_slot(slot: SlotId) -> Self { + Self(slot) + } + + pub(crate) const fn slot(self) -> SlotId { + self.0 + } + + /// Construct a handle from raw parts for unit-test scaffolding in + /// callers that need distinct endpoint references without allocating. + #[cfg(test)] + #[allow(dead_code, reason = "symmetric with TaskHandle::test_handle")] + #[must_use] + pub(crate) const fn test_handle(index: u16, generation: u32) -> Self { + Self(SlotId::from_parts(index, generation)) + } +} + +/// The concrete arena type for endpoints. +pub type EndpointArena = Arena; + +/// Allocate an endpoint in `arena`. +/// +/// # Errors +/// +/// [`ObjError::ArenaFull`] when every slot is in use. +pub fn create_endpoint( + arena: &mut EndpointArena, + endpoint: Endpoint, +) -> Result { + arena + .allocate(endpoint) + .map(EndpointHandle::from_slot) + .ok_or(ObjError::ArenaFull) +} + +/// Free the endpoint at `handle`. +/// +/// # Errors +/// +/// [`ObjError::InvalidHandle`] when `handle` is stale or already freed. +pub fn destroy_endpoint( + arena: &mut EndpointArena, + handle: EndpointHandle, +) -> Result { + arena.free(handle.slot()).ok_or(ObjError::InvalidHandle) +} + +/// Return a reference to the endpoint at `handle`. +#[must_use] +pub fn get_endpoint(arena: &EndpointArena, handle: EndpointHandle) -> Option<&Endpoint> { + arena.get(handle.slot()) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + reason = "tests may use pragmas forbidden in production kernel code" +)] +mod tests { + use super::{create_endpoint, destroy_endpoint, get_endpoint, Endpoint, EndpointArena}; + + #[test] + fn create_destroy_round_trip() { + let mut arena = EndpointArena::default(); + let handle = create_endpoint(&mut arena, Endpoint::new(42)).unwrap(); + assert_eq!(get_endpoint(&arena, handle).map(Endpoint::id), Some(42)); + let removed = destroy_endpoint(&mut arena, handle).unwrap(); + assert_eq!(removed.id(), 42); + assert!(get_endpoint(&arena, handle).is_none()); + } +} diff --git a/kernel/src/obj/mod.rs b/kernel/src/obj/mod.rs new file mode 100644 index 0000000..ceec6e5 --- /dev/null +++ b/kernel/src/obj/mod.rs @@ -0,0 +1,69 @@ +//! Kernel-object subsystem. +//! +//! Every capability points at a kernel object. This module owns the +//! object types ([`Task`], [`Endpoint`], [`Notification`]), their typed +//! handles ([`TaskHandle`], [`EndpointHandle`], [`NotificationHandle`]), +//! their per-type arenas, and the create / destroy APIs that produce +//! and consume them. +//! +//! The storage shape is pinned in [ADR-0016][adr-0016]: per-type +//! fixed-size-block arenas, generation-tagged typed handles, global +//! ownership, zero `unsafe`. Rationale is unchanged from the capability +//! table ([ADR-0014][adr-0014]); [`Arena`] is the audited pattern +//! generalised and instantiated three times. +//! +//! [adr-0014]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0014-capability-representation.md +//! [adr-0016]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0016-kernel-object-storage.md +//! +//! ## Status (v1, T-002) +//! +//! - Three kinds: [`Task`], [`Endpoint`], [`Notification`]. `MemoryRegion` +//! is deferred to Phase B. +//! - Typed handles prevent cross-kind confusion at compile time. +//! - Lifecycle is explicit destruction; a reachability check against a +//! given set of capability tables is available through +//! [`crate::cap::CapabilityTable::references_object`] but is *not* +//! automatically performed by the destroy functions. Callers that +//! need the check wire it in at their call site; a successor ADR will +//! bundle it when the kernel owns a registry of tables. +//! - All v1 kernel-object code is safe Rust. + +pub mod arena; +pub mod endpoint; +pub mod notification; +pub mod task; + +pub use endpoint::{Endpoint, EndpointArena, EndpointHandle}; +pub use notification::{Notification, NotificationArena, NotificationHandle}; +pub use task::{Task, TaskArena, TaskHandle}; + +/// Compile-time bound on the number of live `Task` kernel objects. +/// Conservatively small for v1; revisit when a real deployment asks +/// for more. +pub const TASK_ARENA_CAPACITY: usize = 16; + +/// Compile-time bound on the number of live `Endpoint` kernel objects. +pub const ENDPOINT_ARENA_CAPACITY: usize = 16; + +/// Compile-time bound on the number of live `Notification` kernel objects. +pub const NOTIFICATION_ARENA_CAPACITY: usize = 16; + +/// Errors returned by kernel-object operations. +/// +/// `#[non_exhaustive]` so that variants added as new kinds land are not +/// breaking changes to matches outside the crate. +#[non_exhaustive] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ObjError { + /// The arena of this kind is full; no free slot. + ArenaFull, + /// The handle does not name a live slot — either never allocated, + /// already freed, or stale after reuse. + InvalidHandle, + /// Returned by callers that enforce the reachability invariant: at + /// least one capability table still names the object. The `destroy_*` + /// functions themselves do not walk tables; callers check via + /// [`crate::cap::CapabilityTable::references_object`] and return this + /// variant when any table still names the handle. + StillReachable, +} diff --git a/kernel/src/obj/notification.rs b/kernel/src/obj/notification.rs new file mode 100644 index 0000000..1ef7f5a --- /dev/null +++ b/kernel/src/obj/notification.rs @@ -0,0 +1,154 @@ +//! `Notification` kernel object — v1 skeleton for asynchronous signals. +//! +//! Per [ADR-0016][adr-0016], v1 stores notifications in a per-type +//! [`Arena`][super::arena::Arena] with a typed [`NotificationHandle`]. +//! The v1 state is the single saturating 64-bit word that Milestone A4's +//! `notify` / `wait` operations will OR bits into and read back; the +//! waiter list arrives in A4. +//! +//! [adr-0016]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0016-kernel-object-storage.md + +use super::arena::{Arena, SlotId}; +use super::{ObjError, NOTIFICATION_ARENA_CAPACITY}; + +/// The v1 `Notification` kernel object — a 64-bit saturating signal word. +#[derive(Debug)] +pub struct Notification { + word: u64, +} + +impl Notification { + /// Construct a notification with the initial word (typically `0`). + #[must_use] + pub const fn new(word: u64) -> Self { + Self { word } + } + + /// Current word. + #[must_use] + pub const fn word(&self) -> u64 { + self.word + } + + /// Bit-wise OR `bits` into the word — "saturating" in the sense that + /// once a bit is set, a later `set` against the same bit is a no-op. + pub fn set(&mut self, bits: u64) { + self.word |= bits; + } + + /// Clear every set bit, returning the bits that were set before the + /// clear. This is the "consume" half of the wait/notify pair. + pub fn consume(&mut self) -> u64 { + let current = self.word; + self.word = 0; + current + } +} + +/// Typed handle referring to a notification in a [`NotificationArena`]. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct NotificationHandle(SlotId); + +impl NotificationHandle { + pub(crate) const fn from_slot(slot: SlotId) -> Self { + Self(slot) + } + + pub(crate) const fn slot(self) -> SlotId { + self.0 + } + + /// Construct a handle from raw parts for unit-test scaffolding in + /// callers that need distinct notification references without + /// allocating. + #[cfg(test)] + #[allow(dead_code, reason = "symmetric with TaskHandle::test_handle")] + #[must_use] + pub(crate) const fn test_handle(index: u16, generation: u32) -> Self { + Self(SlotId::from_parts(index, generation)) + } +} + +/// The concrete arena type for notifications. +pub type NotificationArena = Arena; + +/// Allocate a notification in `arena`. +/// +/// # Errors +/// +/// [`ObjError::ArenaFull`] when every slot is in use. +pub fn create_notification( + arena: &mut NotificationArena, + notification: Notification, +) -> Result { + arena + .allocate(notification) + .map(NotificationHandle::from_slot) + .ok_or(ObjError::ArenaFull) +} + +/// Free the notification at `handle`. +/// +/// # Errors +/// +/// [`ObjError::InvalidHandle`] when `handle` is stale or already freed. +pub fn destroy_notification( + arena: &mut NotificationArena, + handle: NotificationHandle, +) -> Result { + arena.free(handle.slot()).ok_or(ObjError::InvalidHandle) +} + +/// Return a reference to the notification at `handle`. +#[must_use] +pub fn get_notification( + arena: &NotificationArena, + handle: NotificationHandle, +) -> Option<&Notification> { + arena.get(handle.slot()) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + reason = "tests may use pragmas forbidden in production kernel code" +)] +mod tests { + use super::{ + create_notification, destroy_notification, get_notification, Notification, + NotificationArena, + }; + + #[test] + fn set_and_consume_round_trip() { + let mut arena = NotificationArena::default(); + let handle = create_notification(&mut arena, Notification::new(0)).unwrap(); + + let note = arena.get_mut(handle.slot()).unwrap(); + note.set(0b0001); + note.set(0b0100); + assert_eq!( + get_notification(&arena, handle).map(Notification::word), + Some(0b0101) + ); + + let note = arena.get_mut(handle.slot()).unwrap(); + assert_eq!(note.consume(), 0b0101); + assert_eq!(note.consume(), 0); + } + + #[test] + fn destroy_invalidates_handle() { + let mut arena = NotificationArena::default(); + let handle = create_notification(&mut arena, Notification::new(0)).unwrap(); + destroy_notification(&mut arena, handle).unwrap(); + assert!(get_notification(&arena, handle).is_none()); + // Reallocating reuses the same slot with a bumped generation; the + // original handle must still fail lookup (generation mismatch). + let _new_handle = create_notification(&mut arena, Notification::new(1)).unwrap(); + assert!( + get_notification(&arena, handle).is_none(), + "stale handle must fail after slot reuse" + ); + } +} diff --git a/kernel/src/obj/task.rs b/kernel/src/obj/task.rs new file mode 100644 index 0000000..154a382 --- /dev/null +++ b/kernel/src/obj/task.rs @@ -0,0 +1,149 @@ +//! `Task` kernel object — v1 skeleton. +//! +//! A `Task` is the kernel's representation of a scheduled execution +//! context. Per [ADR-0016][adr-0016], v1 stores tasks in a per-type +//! [`Arena`][super::arena::Arena] with a typed [`TaskHandle`]; scheduler +//! state and the context-save frame arrive in Milestone A5 as layered +//! additions. +//! +//! [adr-0016]: https://github.com/cemililik/UmbrixOS/blob/main/docs/decisions/0016-kernel-object-storage.md + +use super::arena::{Arena, SlotId}; +use super::{ObjError, TASK_ARENA_CAPACITY}; + +/// The v1 `Task` kernel object. +/// +/// Minimal fields — enough for the capability-to-object wiring T-002 +/// delivers. Phase A5 adds scheduler state; Phase B adds address-space +/// ownership. +#[derive(Debug)] +pub struct Task { + id: u32, +} + +impl Task { + /// Construct a task with the given identifier. + #[must_use] + pub const fn new(id: u32) -> Self { + Self { id } + } + + /// Return the task's identifier. + #[must_use] + pub const fn id(&self) -> u32 { + self.id + } +} + +/// Typed handle referring to a task in a [`TaskArena`]. +/// +/// `TaskHandle` is intentionally not convertible to or from other kinds' +/// handles: the type system prevents e.g. passing a `TaskHandle` where +/// an `EndpointHandle` is expected. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct TaskHandle(SlotId); + +impl TaskHandle { + pub(crate) const fn from_slot(slot: SlotId) -> Self { + Self(slot) + } + + pub(crate) const fn slot(self) -> SlotId { + self.0 + } + + /// Construct a handle from raw `(index, generation)` for tests that + /// need to compose capabilities without allocating through a real + /// arena. Production code obtains handles via [`create_task`]. + #[cfg(test)] + #[must_use] + pub(crate) const fn test_handle(index: u16, generation: u32) -> Self { + Self(SlotId::from_parts(index, generation)) + } +} + +/// The concrete arena type for tasks. Capacity is [`TASK_ARENA_CAPACITY`]. +pub type TaskArena = Arena; + +/// Allocate a task in `arena`, returning a [`TaskHandle`] that names it. +/// +/// # Errors +/// +/// [`ObjError::ArenaFull`] when every slot is in use. +pub fn create_task(arena: &mut TaskArena, task: Task) -> Result { + arena + .allocate(task) + .map(TaskHandle::from_slot) + .ok_or(ObjError::ArenaFull) +} + +/// Free the task at `handle`, returning the stored value. +/// +/// v1 does not itself walk capability tables to enforce reachability; +/// callers that hold references to live tables should check via +/// [`CapabilityTable::references_object`][crate::cap::CapabilityTable::references_object] +/// first and pass [`ObjError::StillReachable`] back to their own caller +/// if any table still names this handle. A successor ADR will bundle +/// the check into this function once the kernel owns a registry of +/// tables. +/// +/// # Errors +/// +/// [`ObjError::InvalidHandle`] when `handle` is stale or already freed. +pub fn destroy_task(arena: &mut TaskArena, handle: TaskHandle) -> Result { + arena.free(handle.slot()).ok_or(ObjError::InvalidHandle) +} + +/// Return a reference to the task at `handle`, or `None` if the handle +/// is stale. +#[must_use] +pub fn get_task(arena: &TaskArena, handle: TaskHandle) -> Option<&Task> { + arena.get(handle.slot()) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + reason = "tests may use pragmas forbidden in production kernel code" +)] +mod tests { + use super::{create_task, destroy_task, get_task, Task, TaskArena}; + use crate::obj::{ObjError, TASK_ARENA_CAPACITY}; + + #[test] + fn create_then_get_round_trip() { + let mut arena = TaskArena::default(); + let handle = create_task(&mut arena, Task::new(7)).unwrap(); + assert_eq!(get_task(&arena, handle).map(Task::id), Some(7)); + } + + #[test] + fn destroy_invalidates_handle() { + let mut arena = TaskArena::default(); + let handle = create_task(&mut arena, Task::new(1)).unwrap(); + let removed = destroy_task(&mut arena, handle).unwrap(); + assert_eq!(removed.id(), 1); + assert!(get_task(&arena, handle).is_none()); + assert_eq!( + destroy_task(&mut arena, handle).unwrap_err(), + ObjError::InvalidHandle + ); + } + + #[test] + fn arena_exhaustion_returns_arena_full() { + let mut arena = TaskArena::default(); + for i in 0..TASK_ARENA_CAPACITY { + // `i` fits in u32 because TASK_ARENA_CAPACITY is small. + #[allow( + clippy::cast_possible_truncation, + reason = "bounded by TASK_ARENA_CAPACITY" + )] + create_task(&mut arena, Task::new(i as u32)).unwrap(); + } + assert_eq!( + create_task(&mut arena, Task::new(99)).unwrap_err(), + ObjError::ArenaFull + ); + } +}