diff --git a/.gitignore b/.gitignore index 0233772..5b673f3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,20 @@ Thumbs.db .sentrux/cache.bin .sentrux/churn.bin -# Crash dumps must never enter source control. -core -core.* +# Crash artifacts, never repo state. A core dump is a memory image of a process +# that ran holding live credentials, so an automated fix round staging one into a +# public repo is a credential exposure, not bloat. +# `**/` matches at any depth because a dump lands in the CRASHING PROCESS's working +# directory, which is not always the repo root: `cargo test` runs each package's +# test binary with cwd at that package root, so a Rust workspace dumps to +# `crates//core`. +# `!**/core/` is what makes the depth safe: without it, `**/core` would also match +# every directory named `core` and silently untrack whole subsystems such as +# `src/core/` or a `crates/core` package. +# Two dump name shapes are covered because `kernel.core_pattern` is per-host: +# `core` and `core.` (this container uses the bare `core` form), and +# `core..` from the common `core.%e.%p` pattern. +**/core +!**/core/ +**/core.[0-9]* +**/core.*.[0-9]* diff --git a/AGENTS.md b/AGENTS.md index d48e094..7627524 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,9 @@ Start with `docs/README.md`. Read `docs/IMPLEMENTATION_PLAN.md`, `docs/ARCHITECT ## Repository rules - Keep shared wire types in `crates/contracts`, and keep `schemas/*.json` in sync with them; the contract tests enforce matching fields and enum strings. +- Give every field whose checked-in schema constrains it beyond `type` (a `minLength`, a `minItems`, a `pattern`, a numeric bound) or that carries a `deserialize_with` validator its own dedicated test that binds for that constraint each checked-in schema carrying it, not merely one of them; for a `deserialize_with` validator, which no checked-in schema can state, that test binds each checked-in schema publishing that field and asserts that field's declared `type` there as the counterpart. + A test binds a schema for a constraint by opening the checked-in file and asserting that constraint there; a sibling that only asserts what the Rust type rejects binds nothing and is not the pattern to copy. `protocol_version_zero_is_rejected_like_the_schema` and `the_hypothesis_is_bounded_like_the_schema` bind that way and are also complete for the constraints they assert, each opening the schema that carries it: `schemas/sidecar.schema.json` carries the `protocol_version` minimum alone, and `schemas/experiment.schema.json` the hypothesis length bounds alone. `change_request_parameters_are_an_object_in_both` binds that way for a declared `type`, but does so for fewer checked-in schemas than carry the one it asserts, the class registered as `fpsm-unbound-carrier-parity`. + Schema parity reaches property names, `required`, and `additionalProperties` only, so a mismatched `type` or a dropped bound otherwise stays green wherever the comparison is made: `assert_object_parity` and `assert_same_shape` compare each of those keywords between the generated and checked-in schemas; `response_variants_match_schema` hand-rolls the property-name and `required` comparison between those same sides but pins `additionalProperties` to `false` on the checked-in side rather than comparing it, so a generated side that drifts on that keyword stays green there; and `capability_fields_match_capability_schema` and `manifest_fields_match_sidecar_schema` compare the field set serde emits for a sample value against the checked-in `properties` and `required` and pin `additionalProperties` to `false` on that same checked-in side, holding the wire output to the checked-in schema but taking no generated schema in, so a generated side that drifts on any of the three stays green at those sites. Some checked-in schemas carrying such a constraint are bound for it by no test today, and the deferred contract work list in `docs/ARCHITECTURE.md` registers that gap in both `fpsm-capid-guard`, where writing a guard that proves parity is blocked, and `fpsm-unbound-carrier-parity`, where writing such a guard is merely pending; the missing binding test is writable under either. - Keep provider lifecycle behavior in `crates/provider-sdk`. - Keep the capability registry, policy, broker lifecycle, and experiment journal in `crates/control-plane`. - Keep the local IPC transport, framing, and peer-authentication seams behind traits in `crates/ipc` (Unix domain socket now, Windows named pipe later); the privileged broker in `apps/broker` composes them over the control plane and enforces peer auth, catalog policy, and single-owner-per-knob. The non-`Send` control plane is confined to one worker thread reached through a `Send` handle. diff --git a/crates/contracts/src/lib.rs b/crates/contracts/src/lib.rs index 6a52f37..532840f 100644 --- a/crates/contracts/src/lib.rs +++ b/crates/contracts/src/lib.rs @@ -489,6 +489,18 @@ mod tests { } /// Asserts that two object schemas declare the same fields. + /// + /// Property names, the `required` list, and `additionalProperties` are + /// compared; neither property types nor bounds are, so a `type` that + /// disagrees passes here just as a dropped bound does. A field whose + /// checked-in schema constrains it beyond a bare `type` (a `minLength`, a + /// `minItems`, a `pattern`, a numeric bound) or that carries a + /// `deserialize_with` validator requires its own dedicated test instead, + /// one that binds for that constraint each checked-in schema carrying it + /// rather than merely one of them; for a `deserialize_with` validator, + /// which no checked-in schema can state, that test binds each checked-in + /// schema publishing that field and asserts that field's declared `type` + /// there as the counterpart. fn assert_object_parity(label: &str, generated: &Value, checked_in: &Value) { let generated_properties: BTreeSet = generated["properties"] .as_object() diff --git a/crates/contracts/src/test_support.rs b/crates/contracts/src/test_support.rs index cf38242..4454a4e 100644 --- a/crates/contracts/src/test_support.rs +++ b/crates/contracts/src/test_support.rs @@ -1,8 +1,11 @@ //! Shared helpers for the schema-synchronization tests. //! //! `schemas/*.json` are hand-written and must agree with the types in this -//! crate. Every schema-sync test module compares them the same way, so the -//! comparison lives here once rather than being re-derived per module. +//! crate. The helpers here collect the pieces those tests compare, and +//! `assert_same_shape` is one body of that comparison: the tests in `ipc` call +//! it, while `lib` has its own `assert_object_parity` beside field-set tests +//! that hold a serialized sample against a checked-in schema with no generated +//! schema in hand. use std::collections::BTreeSet; @@ -56,7 +59,16 @@ pub fn serialized_fields(value: impl serde::Serialize) -> BTreeSet { /// Asserts that `generated` and `checked_in` declare the same object shape. /// /// Property names, the `required` list, and `additionalProperties` are compared; -/// a definition that drifts in any of the three fails the calling test. +/// a definition that drifts in any of the three fails the calling test. Neither +/// property types nor bounds are compared, so a `type` that disagrees passes +/// here just as a dropped bound does. A field whose checked-in schema constrains +/// it beyond a bare `type` (a `minLength`, a `minItems`, a `pattern`, a numeric +/// bound) or that carries a `deserialize_with` validator requires its own +/// dedicated test instead, one that binds for that constraint each checked-in +/// schema carrying it rather than merely one of them; for a `deserialize_with` +/// validator, which no checked-in schema can state, that test binds each +/// checked-in schema publishing that field and asserts that field's declared +/// `type` there as the counterpart. pub fn assert_same_shape(generated: &schemars::Schema, checked_in: &Value) { let generated = serde_json::to_value(generated).expect("generated schema should serialize"); assert_eq!(properties(&generated), properties(checked_in)); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aecf74a..2c6551d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,7 +19,7 @@ The privileged Windows service accepts authenticated local requests from the gat `apps/broker` implements this on the Linux-safe path. It owns the control plane on a dedicated worker thread and serves exactly two operations - capability discovery and a bounded provider lifecycle - to authenticated local peers over the transport seam in `crates/ipc` (a Unix domain socket now, a Windows named pipe later). Three fail-closed checks guard the boundary: peer authentication before any request is read (a Linux `SO_PEERCRED` same-uid ACL, shaped so a Windows SID ACL can satisfy the same trait), a capability-catalog check that rejects raw shell, arbitrary Registry paths, and out-of-catalog ids, and single-owner-per-knob enforcement that refuses a second concurrent owner of a setting. -Typed request and response messages live in `crates/contracts` with `schemas/broker-request.schema.json` and `schemas/broker-response.schema.json` kept in sync; a malformed frame is rejected with a typed error without taking the broker down. +Typed request and response messages live in `crates/contracts` with `schemas/broker-request.schema.json` and `schemas/broker-response.schema.json` kept in sync as far as the contract tests reach; a malformed frame is rejected with a typed error without taking the broker down. A frame is a big-endian `u32` body length followed by that many bytes of JSON, bounded at one mebibyte so a hostile peer cannot force an unbounded allocation; the framing is transport-agnostic, so a named-pipe transport would reuse it unchanged. At most 32 connections are served at once, and one that stalls for 30 seconds in either direction is closed, so a peer cannot pin a task, a descriptor, or a frame buffer; a peer refused by the ACL gets a far shorter deadline to take its rejection frame, because it has not authenticated and must not be able to hold a connection slot for the full idle budget. A response is a tagged union of its outcome and that outcome's payload, so a tag without its payload never crosses the boundary and no consumer has to unwrap one. @@ -99,6 +99,15 @@ The candidate is also measured before it is applied, which only the pure stand-i Each sidecar integrates exactly one service or vendor API. Sidecars advertise semantic capabilities and implement snapshot, preview, apply, verify, and rollback. They do not make cross-provider decisions. +## Deferred contract work + +These span `crates/contracts` and the checked-in schemas rather than any one process, so they are tracked here rather than under a process heading. +Where an entry states a class rather than a single item it does not enumerate that class: membership is derivable by sweeping the checked-in schemas for constraining keywords and the Rust types for a field carrying a `deserialize_with` validator, which no checked-in schema can state, then applying the entry's own criterion per constraint to that constraint's carriers - for a keyword, the checked-in schemas stating it; for a validator, the checked-in schemas publishing that field. + +- `fpsm-lease-ceiling-parity`: the missing `maximum` on `$defs.ChangeRequest.lease_seconds` in `schemas/broker-request.schema.json`, which declares only a minimum while `MAX_LEASE_SECONDS` and the change request lease in `schemas/experiment.schema.json` both carry the ceiling, and the gateway's `tools/list` input schema, which states the ceiling independently as the `MAX_LEASE_SECONDS` doc comment intends but which no test compares against that constant in agreement. Raising `MAX_LEASE_SECONDS` on its own is caught: within `lease_seconds_is_bounded_like_the_schema` the assertion comparing the checked-in experiment literal against the constant fails the moment the constant moves, while the assertion comparing the generated value against that same constant moves with it and stays green. Once the experiment schema has been brought along, nothing is left to fail and the gateway advertises a stale ceiling with the suite green. Closing this is the `maximum` added to the broker request schema, `lease_seconds_is_bounded_like_the_schema` binding that schema for the ceiling in the same change - or the ceiling clause in `fpsm-unbound-carrier-parity` goes false the moment that edit lands - and a test comparing the gateway's inline ceiling against the constant, still not a reference from the gateway to it. The control-plane policy check rejects an over-ceiling lease, so this is drift in the published contract rather than in enforcement. +- `fpsm-unbound-carrier-parity`: the class of constraint whose dedicated test binds fewer checked-in schemas than carry it, leaving the unbound carriers free to drift with the suite green. The quantifier ranges over each checked-in schema carrying the triggering constraint, read per constraint rather than per field, so one field can be compliant for one of its constraints and not for another, and a carried constraint no test asserts anywhere is inside the class whether a lone schema carries it or several do. What separates this class from `fpsm-capid-guard` is that the Rust field here carries a counterpart for the triggering constraint itself, read per constraint as above: `schemars` emits the minimum for the non-zero lease, and the declared `type` where the attribute names the field a JSON object map. So a guard that proves parity is writable today and the work is pending rather than blocked. A plain `String` also emits a declared `type`, but nothing that a `pattern` or a `minLength` can be held against, which is why `capability_id` stays with the blocked class. The lease floor is the worked example: the change request lease declares the same minimum under `$defs.ChangeRequest` in `schemas/broker-request.schema.json` and under `$defs.change_request` in `schemas/experiment.schema.json`, and no test binds either schema for that minimum - `lease_seconds_is_bounded_like_the_schema` asserts only the ceiling, and `lease_seconds_zero_is_rejected` asserts only what the Rust type rejects, which binds nothing. That same field is compliant for its ceiling: `lease_seconds_is_bounded_like_the_schema` binds for that ceiling every checked-in schema that carries it. `ChangeRequest::parameters` is a second instance: `change_request_parameters_are_an_object_in_both` binds the broker request schema for that field's declared `type`, comparing it against the generated schema, while `schemas/experiment.schema.json` publishes the same declared `type` and nothing asserts it there. A lone carrier falls in the same way: `DecisionBounds::min_samples` declares its minimum in `schemas/experiment.schema.json` alone, and `decision_bounds_are_bounded_like_the_schema` skips that keyword while asserting the improvement floor and the exclusive minimums beside it out of the same envelope, so the hole is per keyword in a table that demonstrably does floors. +- `fpsm-capid-guard`: the dedicated guard `AGENTS.md` requires cannot prove parity today for the class of constraint a checked-in schema carries with no Rust-side counterpart to compare against, because the Rust field is a plain type with no constraint attribute and the generated schema therefore states nothing a guard could hold the checked-in constraint against. A guard that opens the checked-in file and asserts the constraint is writable today, but it pins that file against itself and proves no parity. `ChangeRequest::capability_id` is the worked example, and it carries a second blocker of its own: its checked-in schemas constrain it differently from each other, a `pattern` in one and a `minLength` in the other. `ProviderManifest::targets` is a further member with no such second blocker: `schemas/sidecar.schema.json` requires its array to be non-empty through `minItems`, while the Rust field is a bare `Vec` carrying no attribute, so the generated schema states nothing that keyword can be held against. Closing the class needs each such Rust type to carry its constraint, and for `capability_id` the disagreeing schemas settled onto one, before a guard can prove parity; that prerequisite chain is what a parity guard waits on, while the binding test `AGENTS.md` requires is writable today and simply missing. + ## Dependency rule Dependencies point inward: