feat(daemon,ops): daemon surface + ontology-content implementation
Substantive changes across the crate tree (cargo check --workspace: clean): - ontoref-daemon: UI handlers/pages, MCP surface, api, domain query delegation - ontoref-ops: runtime - ontoref-ontology / ontoref-ontology-content / oplog / triples / commit: supporting - domains/rustelo: schema surface sync
This commit is contained in:
parent
a4edc9e95a
commit
234432856c
106 changed files with 1448 additions and 1078 deletions
|
|
@ -15,17 +15,16 @@ use crate::store::BlobId;
|
|||
///
|
||||
/// Contract any backend MUST honour:
|
||||
///
|
||||
/// 1. `put(bytes)` returns the address `BlobId::from_bytes(bytes)` —
|
||||
/// addressing is by blake3, identical across backends.
|
||||
/// 2. `get(id)` returns the exact bytes that produced `id`, or an
|
||||
/// error. Corrupted bytes MUST be rejected with
|
||||
/// [`crate::Error::Corrupt`].
|
||||
/// 3. `exists(id)` is consistent with `get(id)`: it returns true iff
|
||||
/// a subsequent `get(id)` would not return `NotFound`. It MUST NOT
|
||||
/// perform an integrity check (use `get` for that).
|
||||
/// 4. `list_prefix(prefix_hex)` returns every id whose hex
|
||||
/// representation starts with `prefix_hex`. An empty `prefix_hex`
|
||||
/// returns every id known to the backend.
|
||||
/// 1. `put(bytes)` returns the address `BlobId::from_bytes(bytes)` — addressing
|
||||
/// is by blake3, identical across backends.
|
||||
/// 2. `get(id)` returns the exact bytes that produced `id`, or an error.
|
||||
/// Corrupted bytes MUST be rejected with [`crate::Error::Corrupt`].
|
||||
/// 3. `exists(id)` is consistent with `get(id)`: it returns true iff a
|
||||
/// subsequent `get(id)` would not return `NotFound`. It MUST NOT perform an
|
||||
/// integrity check (use `get` for that).
|
||||
/// 4. `list_prefix(prefix_hex)` returns every id whose hex representation
|
||||
/// starts with `prefix_hex`. An empty `prefix_hex` returns every id known to
|
||||
/// the backend.
|
||||
pub trait BlobBackend {
|
||||
/// Store `bytes` and return its content address.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ pub enum Error {
|
|||
/// Canonical encoding/decoding failure.
|
||||
#[error("canonical: {0}")]
|
||||
Canonical(#[from] ontoref_types::Error),
|
||||
/// Requested cell does not exist in the commit layer; no witness can be built.
|
||||
/// Requested cell does not exist in the commit layer; no witness can be
|
||||
/// built.
|
||||
#[error("cell not found in commit layer")]
|
||||
CellNotFound,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,10 +57,7 @@ impl MergePolicy {
|
|||
|
||||
/// Merge kind for `attr` — `Lww` when undeclared.
|
||||
pub fn kind_for(&self, attr: &AttrId) -> CellMergeKind {
|
||||
self.by_attr
|
||||
.get(attr.as_str())
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
self.by_attr.get(attr.as_str()).copied().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,8 @@ pub mod tree;
|
|||
|
||||
pub use error::{Error, Result};
|
||||
pub use layer::{BinaryMerkle, CellMergeKind, MergePolicy};
|
||||
pub use tree::{cell_key, empty_root, verify_witness, CellKey, PathStep, StateRoot, Witness};
|
||||
|
||||
use ontoref_types::{AttrId, EntityId, Operation};
|
||||
pub use tree::{cell_key, empty_root, verify_witness, CellKey, PathStep, StateRoot, Witness};
|
||||
|
||||
/// Pluggable commitment-backend trait (ADR-026 / D15).
|
||||
///
|
||||
|
|
@ -39,15 +38,14 @@ use ontoref_types::{AttrId, EntityId, Operation};
|
|||
/// Contract any backend MUST honour:
|
||||
///
|
||||
/// 1. `apply(op)` is the only mutation channel; calling it with the same
|
||||
/// sequence of operations from any initial state must yield the same
|
||||
/// state root.
|
||||
/// 2. `root()` is deterministic — no clock, no randomness, no
|
||||
/// instance-specific state may influence the bytes.
|
||||
/// 3. A witness emitted by `witness(entity, attr)` MUST verify against
|
||||
/// the root returned by the same instance immediately after the call.
|
||||
/// 4. `verify_witness(w, r)` is a pure function — backends MUST NOT
|
||||
/// consult any external state and MUST return the same bool for the
|
||||
/// same inputs.
|
||||
/// sequence of operations from any initial state must yield the same state
|
||||
/// root.
|
||||
/// 2. `root()` is deterministic — no clock, no randomness, no instance-specific
|
||||
/// state may influence the bytes.
|
||||
/// 3. A witness emitted by `witness(entity, attr)` MUST verify against the root
|
||||
/// returned by the same instance immediately after the call.
|
||||
/// 4. `verify_witness(w, r)` is a pure function — backends MUST NOT consult any
|
||||
/// external state and MUST return the same bool for the same inputs.
|
||||
pub trait CommitmentBackend {
|
||||
/// Apply one operation. Successful application updates internal state
|
||||
/// such that `root()` reflects the operation's effects.
|
||||
|
|
|
|||
|
|
@ -103,10 +103,7 @@ pub(crate) fn merkle_root(entries: &[(CellKey, &[u8])]) -> StateRoot {
|
|||
/// `entries`. Returns `None` if the key is absent.
|
||||
///
|
||||
/// `entries` must be sorted by key (same constraint as `merkle_root`).
|
||||
pub(crate) fn build_witness(
|
||||
entries: &[(CellKey, &[u8])],
|
||||
target_key: &CellKey,
|
||||
) -> Option<Witness> {
|
||||
pub(crate) fn build_witness(entries: &[(CellKey, &[u8])], target_key: &CellKey) -> Option<Witness> {
|
||||
let index = entries.iter().position(|(k, _)| k == target_key)?;
|
||||
let value = entries[index].1.to_vec();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_commit::{verify_witness, CommitLayer};
|
||||
use ontoref_types::{
|
||||
operation::{OpBody, OpPayload, Operation},
|
||||
AttrId, EntityId, Hlc, PublicKey, Value,
|
||||
};
|
||||
|
||||
use ontoref_commit::{verify_witness, CommitLayer};
|
||||
|
||||
fn signing_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[11u8; 32])
|
||||
}
|
||||
|
|
@ -40,17 +39,10 @@ fn fixture_ops() -> Vec<Operation> {
|
|||
),
|
||||
assert_op(
|
||||
"bob",
|
||||
vec![
|
||||
("name", Value::Str("Bob".into())),
|
||||
("age", Value::Int(25)),
|
||||
],
|
||||
vec![("name", Value::Str("Bob".into())), ("age", Value::Int(25))],
|
||||
Hlc::new(2, 0),
|
||||
),
|
||||
assert_op(
|
||||
"alice",
|
||||
vec![("age", Value::Int(31))],
|
||||
Hlc::new(3, 0),
|
||||
),
|
||||
assert_op("alice", vec![("age", Value::Int(31))], Hlc::new(3, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +79,10 @@ fn determinism_root_changes_with_value_overwrite() {
|
|||
Hlc::new(2, 0),
|
||||
));
|
||||
let r2 = layer.root();
|
||||
assert_ne!(r1, r2, "root must change when an existing cell's value changes");
|
||||
assert_ne!(
|
||||
r1, r2,
|
||||
"root must change when an existing cell's value changes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -224,8 +219,16 @@ fn union_cell_accumulates_and_is_order_independent() {
|
|||
|
||||
// Two actors assert different tag elements on the same cell. Applied in
|
||||
// either order, the cell must hold BOTH and hash to the same root.
|
||||
let a = assert_op("doc", vec![("tags", Value::Str("red".into()))], Hlc::new(1, 0));
|
||||
let b = assert_op("doc", vec![("tags", Value::Str("blue".into()))], Hlc::new(2, 0));
|
||||
let a = assert_op(
|
||||
"doc",
|
||||
vec![("tags", Value::Str("red".into()))],
|
||||
Hlc::new(1, 0),
|
||||
);
|
||||
let b = assert_op(
|
||||
"doc",
|
||||
vec![("tags", Value::Str("blue".into()))],
|
||||
Hlc::new(2, 0),
|
||||
);
|
||||
|
||||
let mut forward = CommitLayer::new().with_policy(policy.clone());
|
||||
forward.apply(&a);
|
||||
|
|
@ -266,5 +269,9 @@ fn lww_is_unchanged_without_a_policy() {
|
|||
// Equals a layer that only ever saw the winning (b) assertion.
|
||||
let mut only_b = CommitLayer::new();
|
||||
only_b.apply(&b);
|
||||
assert_eq!(layer.root(), only_b.root(), "default policy stays last-writer-wins");
|
||||
assert_eq!(
|
||||
layer.root(),
|
||||
only_b.root(),
|
||||
"default policy stays last-writer-wins"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Composition layer integrating L1 through L5 of the ontoref verifiable substrate.
|
||||
//! Composition layer integrating L1 through L5 of the ontoref verifiable
|
||||
//! substrate.
|
||||
//!
|
||||
//! `Substrate::open(path)` materialises a project on disk: blob store, op log,
|
||||
//! triple store; the in-memory commit layer is rebuilt by replaying the op log
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_commit::{empty_root, verify_witness};
|
||||
use ontoref_core::Substrate;
|
||||
use ontoref_types::{
|
||||
operation::{OpBody, OpPayload, Operation},
|
||||
AttrId, EntityId, Hlc, PublicKey, Value,
|
||||
};
|
||||
|
||||
use ontoref_core::Substrate;
|
||||
|
||||
fn signing_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[17u8; 32])
|
||||
}
|
||||
|
|
@ -109,11 +108,7 @@ fn apply_pipeline_materialises_triples_visible_to_query() {
|
|||
let attrs = substrate
|
||||
.query()
|
||||
.triples()
|
||||
.entity_attrs(
|
||||
&EntityId::new("alice"),
|
||||
Hlc::new(10, 0),
|
||||
Hlc::new(10, 0),
|
||||
)
|
||||
.entity_attrs(&EntityId::new("alice"), Hlc::new(10, 0), Hlc::new(10, 0))
|
||||
.unwrap();
|
||||
assert_eq!(attrs.len(), 1);
|
||||
assert_eq!(attrs[0].0, AttrId::new("name"));
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
//!
|
||||
//! 1. The op log holds at least every op the child reported as completed.
|
||||
//! 2. At most one extra op is present (the in-flight one at kill time).
|
||||
//! 3. Reopening the substrate twice produces the same state root —
|
||||
//! i.e. recovery from the durable layer is deterministic.
|
||||
//! 3. Reopening the substrate twice produces the same state root — i.e.
|
||||
//! recovery from the durable layer is deterministic.
|
||||
//!
|
||||
//! Marked `#[ignore]` because it kills a real process; run with
|
||||
//! `cargo test -p ontoref-core --test crash_recovery -- --ignored`.
|
||||
|
|
@ -26,10 +26,10 @@ const PROGRESS_PATH_ENV: &str = "ONTOREF_CRASH_RECOVERY_PROGRESS";
|
|||
const TEST_NAME: &str = "crash_recovery_kill9_during_apply";
|
||||
|
||||
fn run_as_child() -> ! {
|
||||
let sub_path = std::env::var(SUBSTRATE_PATH_ENV)
|
||||
.expect("child requires SUBSTRATE_PATH env var");
|
||||
let progress_path = std::env::var(PROGRESS_PATH_ENV)
|
||||
.expect("child requires PROGRESS_PATH env var");
|
||||
let sub_path =
|
||||
std::env::var(SUBSTRATE_PATH_ENV).expect("child requires SUBSTRATE_PATH env var");
|
||||
let progress_path =
|
||||
std::env::var(PROGRESS_PATH_ENV).expect("child requires PROGRESS_PATH env var");
|
||||
|
||||
let mut substrate = Substrate::open(&sub_path).expect("child failed to open substrate");
|
||||
let key = SigningKey::from_bytes(&[42u8; 32]);
|
||||
|
|
@ -48,8 +48,7 @@ fn run_as_child() -> ! {
|
|||
};
|
||||
let op = Operation::sign(body, &key);
|
||||
substrate.apply(&op).expect("child apply failed");
|
||||
std::fs::write(&progress_path, format!("{counter}"))
|
||||
.expect("child progress write failed");
|
||||
std::fs::write(&progress_path, format!("{counter}")).expect("child progress write failed");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
counter += 1;
|
||||
}
|
||||
|
|
@ -96,11 +95,13 @@ fn crash_recovery_kill9_during_apply() {
|
|||
|
||||
assert!(
|
||||
oplog_len > last_completed,
|
||||
"oplog must hold every reported op (last_completed={last_completed}, oplog_len={oplog_len})"
|
||||
"oplog must hold every reported op (last_completed={last_completed}, \
|
||||
oplog_len={oplog_len})"
|
||||
);
|
||||
assert!(
|
||||
oplog_len < last_completed + 3,
|
||||
"oplog should not be more than one op past the last reported (last_completed={last_completed}, oplog_len={oplog_len})"
|
||||
"oplog should not be more than one op past the last reported \
|
||||
(last_completed={last_completed}, oplog_len={oplog_len})"
|
||||
);
|
||||
|
||||
let root_first = substrate.state_root();
|
||||
|
|
@ -110,6 +111,7 @@ fn crash_recovery_kill9_during_apply() {
|
|||
assert_eq!(
|
||||
substrate_again.state_root(),
|
||||
root_first,
|
||||
"state root must be deterministic across reopens — recovery is a pure function of the durable oplog"
|
||||
"state root must be deterministic across reopens — recovery is a pure function of the \
|
||||
durable oplog"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,13 +80,21 @@ fn two_actors_converge_on_the_same_cell() {
|
|||
bob.apply(&bob_op).expect("bob apply");
|
||||
|
||||
// Diverged before sync.
|
||||
assert_ne!(alice.state_root(), bob.state_root(), "actors diverge pre-sync");
|
||||
assert_ne!(
|
||||
alice.state_root(),
|
||||
bob.state_root(),
|
||||
"actors diverge pre-sync"
|
||||
);
|
||||
|
||||
// Each shares its op, then pulls + ingests the peer's.
|
||||
share(&alice_sync, &alice_op);
|
||||
alice_sync.announce(&hex(&alice_op.id().0)).expect("alice announce");
|
||||
alice_sync
|
||||
.announce(&hex(&alice_op.id().0))
|
||||
.expect("alice announce");
|
||||
share(&bob_sync, &bob_op);
|
||||
bob_sync.announce(&hex(&bob_op.id().0)).expect("bob announce");
|
||||
bob_sync
|
||||
.announce(&hex(&bob_op.id().0))
|
||||
.expect("bob announce");
|
||||
|
||||
let into_bob = pull_and_ingest(&bob_sync, "alice", &mut bob);
|
||||
let into_alice = pull_and_ingest(&alice_sync, "bob", &mut alice);
|
||||
|
|
@ -105,10 +113,15 @@ fn two_actors_converge_on_the_same_cell() {
|
|||
let root_before = alice.state_root();
|
||||
let again = pull_and_ingest(&alice_sync, "bob", &mut alice);
|
||||
assert_eq!(again, 0, "re-ingest is idempotent");
|
||||
assert_eq!(alice.state_root(), root_before, "idempotent ingest leaves the root");
|
||||
assert_eq!(
|
||||
alice.state_root(),
|
||||
root_before,
|
||||
"idempotent ingest leaves the root"
|
||||
);
|
||||
|
||||
for op in alice.oplog().all_ops_in_hlc_order().expect("ops") {
|
||||
op.verify_signature().expect("every converged witness verifies");
|
||||
op.verify_signature()
|
||||
.expect("every converged witness verifies");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +172,9 @@ fn two_actors_converge_with_a_removal() {
|
|||
// The convergent state has the cell removed: it equals a substrate that
|
||||
// only ever saw the (higher-HLC) retract.
|
||||
let mut reference = Substrate::open(dir.path().join("ref")).expect("ref");
|
||||
reference.apply(&retract_op(&b_key, "shared", &["v"], Hlc::new(20, 0))).expect("ref");
|
||||
reference
|
||||
.apply(&retract_op(&b_key, "shared", &["v"], Hlc::new(20, 0)))
|
||||
.expect("ref");
|
||||
assert_eq!(
|
||||
alice.state_root(),
|
||||
reference.state_root(),
|
||||
|
|
@ -195,16 +210,29 @@ fn union_domain_converges_across_actors_via_substrate() {
|
|||
let a_key = SigningKey::from_bytes(&[1u8; 32]);
|
||||
let b_key = SigningKey::from_bytes(&[2u8; 32]);
|
||||
|
||||
let mut alice =
|
||||
Substrate::open_with_policy(dir.path().join("a"), policy.clone()).expect("a");
|
||||
let mut alice = Substrate::open_with_policy(dir.path().join("a"), policy.clone()).expect("a");
|
||||
let mut bob = Substrate::open_with_policy(dir.path().join("b"), policy.clone()).expect("b");
|
||||
|
||||
let oa = assert_attr(&a_key, "doc", "tags", Value::Str("red".into()), Hlc::new(10, 0));
|
||||
let ob = assert_attr(&b_key, "doc", "tags", Value::Str("blue".into()), Hlc::new(20, 0));
|
||||
let oa = assert_attr(
|
||||
&a_key,
|
||||
"doc",
|
||||
"tags",
|
||||
Value::Str("red".into()),
|
||||
Hlc::new(10, 0),
|
||||
);
|
||||
let ob = assert_attr(
|
||||
&b_key,
|
||||
"doc",
|
||||
"tags",
|
||||
Value::Str("blue".into()),
|
||||
Hlc::new(20, 0),
|
||||
);
|
||||
alice.apply(&oa).expect("alice");
|
||||
bob.apply(&ob).expect("bob");
|
||||
|
||||
alice.ingest(std::slice::from_ref(&ob)).expect("alice ingest");
|
||||
alice
|
||||
.ingest(std::slice::from_ref(&ob))
|
||||
.expect("alice ingest");
|
||||
bob.ingest(std::slice::from_ref(&oa)).expect("bob ingest");
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -215,10 +243,15 @@ fn union_domain_converges_across_actors_via_substrate() {
|
|||
|
||||
// The converged root retains BOTH tags: it differs from a substrate that
|
||||
// only ever saw one element (proving union, not last-writer-wins).
|
||||
let mut only_one =
|
||||
Substrate::open_with_policy(dir.path().join("c"), policy).expect("c");
|
||||
let mut only_one = Substrate::open_with_policy(dir.path().join("c"), policy).expect("c");
|
||||
only_one
|
||||
.apply(&assert_attr(&a_key, "doc", "tags", Value::Str("red".into()), Hlc::new(10, 0)))
|
||||
.apply(&assert_attr(
|
||||
&a_key,
|
||||
"doc",
|
||||
"tags",
|
||||
Value::Str("red".into()),
|
||||
Hlc::new(10, 0),
|
||||
))
|
||||
.expect("c apply");
|
||||
assert_ne!(
|
||||
alice.state_root(),
|
||||
|
|
@ -242,13 +275,11 @@ fn semantic_validator_quarantines_a_peer_op_post_merge() {
|
|||
|
||||
// Domain rule: reject assertions to the "forbidden" entity.
|
||||
let report = local
|
||||
.ingest_with(&[good.clone(), bad.clone()], |op| {
|
||||
match &op.body.payload {
|
||||
OpPayload::EntityAssert { entity, .. } if entity.0 == "forbidden" => {
|
||||
Err("entity 'forbidden' is not admissible".to_owned())
|
||||
}
|
||||
_ => Ok(()),
|
||||
.ingest_with(&[good.clone(), bad.clone()], |op| match &op.body.payload {
|
||||
OpPayload::EntityAssert { entity, .. } if entity.0 == "forbidden" => {
|
||||
Err("entity 'forbidden' is not admissible".to_owned())
|
||||
}
|
||||
_ => Ok(()),
|
||||
})
|
||||
.expect("ingest_with");
|
||||
|
||||
|
|
@ -265,5 +296,8 @@ fn semantic_validator_quarantines_a_peer_op_post_merge() {
|
|||
.map(|o| o.id())
|
||||
.collect();
|
||||
assert!(ids.contains(&good.id()));
|
||||
assert!(!ids.contains(&bad.id()), "quarantined op never reaches the oplog");
|
||||
assert!(
|
||||
!ids.contains(&bad.id()),
|
||||
"quarantined op never reaches the oplog"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ fn snapshot_enables_incremental_recovery() {
|
|||
snap.state_root, root_at_snapshot,
|
||||
"snapshot records the state root for direct offline verification"
|
||||
);
|
||||
assert!(snap.boundary.is_some(), "boundary pins the replay resume point");
|
||||
assert!(
|
||||
snap.boundary.is_some(),
|
||||
"boundary pins the replay resume point"
|
||||
);
|
||||
|
||||
// Reopen via the snapshot reproduces the root with nothing new to replay.
|
||||
let root_final = {
|
||||
|
|
|
|||
|
|
@ -438,12 +438,12 @@ fn process_alive(_pid: u32) -> bool {
|
|||
#[cfg(test)]
|
||||
#[test]
|
||||
fn keypair_lifecycle() {
|
||||
use crate::keypair::{
|
||||
gen_for_actor, load_for_actor, public_key_hex, register_actor_in_ncl,
|
||||
sign_witness_payload,
|
||||
};
|
||||
use ontoref_types::signature::{PublicKey, Sig};
|
||||
|
||||
use crate::keypair::{
|
||||
gen_for_actor, load_for_actor, public_key_hex, register_actor_in_ncl, sign_witness_payload,
|
||||
};
|
||||
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let key_dir = tmp.path().join("keys");
|
||||
let actors_ncl = tmp.path().join(".ontoref").join("actors.ncl");
|
||||
|
|
@ -466,14 +466,23 @@ fn keypair_lifecycle() {
|
|||
register_actor_in_ncl(&actors_ncl, actor, &public_key, "2026-05-26T00:00:00Z")
|
||||
.expect("register ok");
|
||||
let ncl = std::fs::read_to_string(&actors_ncl).expect("read actors.ncl");
|
||||
assert!(ncl.contains("public_key"), "actors.ncl must contain public_key");
|
||||
assert!(ncl.contains(&public_key_hex(&public_key)), "actors.ncl must carry exact pubkey hex");
|
||||
assert!(
|
||||
ncl.contains("public_key"),
|
||||
"actors.ncl must contain public_key"
|
||||
);
|
||||
assert!(
|
||||
ncl.contains(&public_key_hex(&public_key)),
|
||||
"actors.ncl must carry exact pubkey hex"
|
||||
);
|
||||
assert!(ncl.contains(actor), "actors.ncl must carry the actor id");
|
||||
|
||||
// 3. load — round-trips the private key from disk
|
||||
let signing_key = load_for_actor(actor, &key_dir).expect("load ok");
|
||||
let derived = PublicKey::from_signing_key(&signing_key);
|
||||
assert_eq!(derived.0, public_key.0, "loaded key must derive same public key");
|
||||
assert_eq!(
|
||||
derived.0, public_key.0,
|
||||
"loaded key must derive same public key"
|
||||
);
|
||||
|
||||
// 4. sign — produces a valid signature over the canonical witness payload
|
||||
let op_id = b"op:move_fsm_state:00000001";
|
||||
|
|
|
|||
|
|
@ -226,7 +226,8 @@ pub struct AppState {
|
|||
///
|
||||
/// Exposes its JWKS at `GET /.well-known/ontoref-keys.json` and is the
|
||||
/// signing key for tokens accepted by SSO-mode panels (federation contract
|
||||
/// with sibling crates that consume `ontoref-auth`'s `SignedTokenProvider`).
|
||||
/// with sibling crates that consume `ontoref-auth`'s
|
||||
/// `SignedTokenProvider`).
|
||||
pub token_issuer: Arc<auth::TokenIssuer>,
|
||||
/// Verifies daemon-minted JWTs against the issuer's JWKS. Refreshed in
|
||||
/// lockstep with `token_issuer` on key rotation.
|
||||
|
|
@ -536,8 +537,8 @@ pub fn router(state: AppState) -> axum::Router {
|
|||
// SSO surface: JWKS publication + token introspection (RFC 7662-shaped).
|
||||
// No auth on either endpoint:
|
||||
// - JWKS is public by definition (anyone verifying a token needs it).
|
||||
// - Introspection takes the token in the request body and reveals only
|
||||
// what was already cryptographically vouched by the issuer.
|
||||
// - Introspection takes the token in the request body and reveals only what was
|
||||
// already cryptographically vouched by the issuer.
|
||||
let app = app
|
||||
.route("/.well-known/ontoref-keys.json", get(jwks_handler))
|
||||
.route("/.session/introspect", post(introspect_handler));
|
||||
|
|
@ -684,7 +685,10 @@ async fn api_catalog_handler() -> impl IntoResponse {
|
|||
async fn ops_dispatch_handler(
|
||||
axum::extract::Path(op_id): axum::extract::Path<String>,
|
||||
Json(request): Json<crate::ops_surface::DispatchRequest>,
|
||||
) -> Result<Json<crate::ops_surface::DispatchResponse>, (axum::http::StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<
|
||||
Json<crate::ops_surface::DispatchResponse>,
|
||||
(axum::http::StatusCode, Json<serde_json::Value>),
|
||||
> {
|
||||
match crate::ops_surface::dispatch_op_request(&op_id, request) {
|
||||
Ok(response) => Ok(Json(response)),
|
||||
Err(e) => Err((
|
||||
|
|
@ -702,7 +706,8 @@ async fn ops_dispatch_handler(
|
|||
path = "/ops",
|
||||
auth = "none",
|
||||
actors = "agent, developer, ci, admin",
|
||||
description = "List every registered domain operation with its declarative shape (MCP-aligned projection)",
|
||||
description = "List every registered domain operation with its declarative shape (MCP-aligned \
|
||||
projection)",
|
||||
tags = "ops, catalog"
|
||||
)]
|
||||
async fn ops_list_handler() -> Json<serde_json::Value> {
|
||||
|
|
@ -727,8 +732,7 @@ struct ChronicleQuery {
|
|||
auth = "none",
|
||||
actors = "agent, developer, ci, admin",
|
||||
params = "since:string:optional:ISO date lower bound (inclusive); \
|
||||
kind:string:optional:adr|migration; \
|
||||
domain:string:optional:Domain filter; \
|
||||
kind:string:optional:adr|migration; domain:string:optional:Domain filter; \
|
||||
slug:string:optional:Project slug",
|
||||
tags = "memory, chronicle"
|
||||
)]
|
||||
|
|
@ -764,8 +768,7 @@ struct RoadmapQuery {
|
|||
auth = "none",
|
||||
actors = "agent, developer, ci, admin",
|
||||
params = "status:string:optional:Open|InProgress|Done|Cancelled; \
|
||||
horizon:string:optional:now|next|later; \
|
||||
slug:string:optional:Project slug",
|
||||
horizon:string:optional:now|next|later; slug:string:optional:Project slug",
|
||||
tags = "memory, roadmap"
|
||||
)]
|
||||
async fn roadmap_handler(
|
||||
|
|
@ -807,13 +810,8 @@ async fn criteria_handler(
|
|||
) -> Json<serde_json::Value> {
|
||||
state.touch_activity();
|
||||
let (root, cache, ip) = project_ctx_tuple(&state, q.slug.as_deref());
|
||||
let criteria = crate::memory::criteria_list(
|
||||
&root,
|
||||
&cache,
|
||||
ip.as_deref(),
|
||||
q.kind.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let criteria =
|
||||
crate::memory::criteria_list(&root, &cache, ip.as_deref(), q.kind.as_deref()).await;
|
||||
Json(serde_json::json!({ "count": criteria.len(), "criteria": criteria }))
|
||||
}
|
||||
|
||||
|
|
@ -829,8 +827,8 @@ struct PanelsQuery {
|
|||
path = "/api/panels",
|
||||
auth = "none",
|
||||
actors = "agent, developer, ci, admin",
|
||||
params = "kind:string:optional:Timeline|Gantt|Graph|Table|Board; \
|
||||
slug:string:optional:Project slug",
|
||||
params = "kind:string:optional:Timeline|Gantt|Graph|Table|Board; slug:string:optional:Project \
|
||||
slug",
|
||||
tags = "memory, panels"
|
||||
)]
|
||||
async fn panels_handler(
|
||||
|
|
@ -839,13 +837,7 @@ async fn panels_handler(
|
|||
) -> Json<serde_json::Value> {
|
||||
state.touch_activity();
|
||||
let (root, cache, ip) = project_ctx_tuple(&state, q.slug.as_deref());
|
||||
let panels = crate::memory::panels_list(
|
||||
&root,
|
||||
&cache,
|
||||
ip.as_deref(),
|
||||
q.kind.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let panels = crate::memory::panels_list(&root, &cache, ip.as_deref(), q.kind.as_deref()).await;
|
||||
Json(serde_json::json!({ "count": panels.len(), "panels": panels }))
|
||||
}
|
||||
|
||||
|
|
@ -1104,9 +1096,8 @@ struct RegisterResponse {
|
|||
auth = "none",
|
||||
actors = "agent, developer, ci",
|
||||
params = "actor_type:string:required:Actor type (agent|developer|ci|admin); \
|
||||
hostname:string:required:Caller hostname (audit trail); \
|
||||
pid:number:required:Caller process id (audit trail); \
|
||||
project:string:required:Project slug to associate with; \
|
||||
hostname:string:required:Caller hostname (audit trail); pid:number:required:Caller \
|
||||
process id (audit trail); project:string:required:Project slug to associate with; \
|
||||
role:string:optional:Actor role; preferences:object:optional:Free-form actor \
|
||||
preferences",
|
||||
tags = "actors, auth"
|
||||
|
|
@ -1439,11 +1430,7 @@ async fn notifications_emit(
|
|||
None,
|
||||
);
|
||||
|
||||
(
|
||||
StatusCode::ACCEPTED,
|
||||
Json(EmitResponse { id, project }),
|
||||
)
|
||||
.into_response()
|
||||
(StatusCode::ACCEPTED, Json(EmitResponse { id, project })).into_response()
|
||||
}
|
||||
|
||||
// ── SSE Notification Stream ───────────────────────────────────────────────
|
||||
|
|
@ -3902,8 +3889,8 @@ struct IntrospectRequest {
|
|||
path = "/.session/introspect",
|
||||
auth = "none",
|
||||
actors = "agent, developer, ci, admin",
|
||||
params = "token:string:required:JWT to introspect; \
|
||||
audience:string:optional:Expected aud (Exact match)",
|
||||
params = "token:string:required:JWT to introspect; audience:string:optional:Expected aud \
|
||||
(Exact match)",
|
||||
tags = "meta, sso"
|
||||
)]
|
||||
async fn introspect_handler(
|
||||
|
|
@ -3959,7 +3946,8 @@ fn persist_keys_overlay(config_dir: &std::path::Path, registry: &crate::registry
|
|||
}
|
||||
}
|
||||
|
||||
// ── Domain graph endpoints ────────────────────────────────────────────────────
|
||||
// ── Domain graph endpoints
|
||||
// ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DomainQuery {
|
||||
|
|
@ -3981,11 +3969,16 @@ async fn domain_list(
|
|||
Query(q): Query<DomainQuery>,
|
||||
) -> impl IntoResponse {
|
||||
state.touch_activity();
|
||||
let ctx = q.slug.as_deref()
|
||||
let ctx = q
|
||||
.slug
|
||||
.as_deref()
|
||||
.and_then(|s| state.registry.get(s))
|
||||
.unwrap_or_else(|| state.registry.primary());
|
||||
let domains = crate::domain::project_domains(&ctx.root);
|
||||
(StatusCode::OK, Json(serde_json::json!({ "slug": ctx.slug, "domains": domains })))
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "slug": ctx.slug, "domains": domains })),
|
||||
)
|
||||
}
|
||||
|
||||
/// All registered projects implementing a given domain id
|
||||
|
|
@ -4004,10 +3997,18 @@ async fn domain_consumers(
|
|||
state.touch_activity();
|
||||
let id = match q.id {
|
||||
Some(ref s) if !s.is_empty() => s.as_str(),
|
||||
_ => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "id required" }))),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "id required" })),
|
||||
)
|
||||
}
|
||||
};
|
||||
let consumers = crate::domain::consumers_of(&state.registry, id);
|
||||
(StatusCode::OK, Json(serde_json::json!({ "domain_id": id, "consumers": consumers })))
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "domain_id": id, "consumers": consumers })),
|
||||
)
|
||||
}
|
||||
|
||||
/// Domain metadata from the ontoref domain registry
|
||||
|
|
@ -4026,7 +4027,12 @@ async fn domain_show(
|
|||
state.touch_activity();
|
||||
let id = match q.id {
|
||||
Some(ref s) if !s.is_empty() => s.as_str(),
|
||||
_ => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "id required" }))),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "id required" })),
|
||||
)
|
||||
}
|
||||
};
|
||||
let root = state.registry.primary().root.clone();
|
||||
// Walk up to find ontoref root (domains/ dir).
|
||||
|
|
@ -4160,9 +4166,10 @@ async fn tier_next(
|
|||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::to_value(projection).unwrap_or_else(|e| {
|
||||
serde_json::json!({ "error": e.to_string() })
|
||||
})),
|
||||
Json(
|
||||
serde_json::to_value(projection)
|
||||
.unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() })),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ use std::path::PathBuf;
|
|||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use ontoref_daemon::config::{OpsConfig, Phase, Tier};
|
||||
use ontoref_daemon::tier_transition::{execute, TransitionContext, TransitionError};
|
||||
|
||||
|
|
@ -181,7 +180,8 @@ fn run() -> Result<(), String> {
|
|||
Ok(outcome) => {
|
||||
if json {
|
||||
println!(
|
||||
"{{\"ok\":true,\"from\":\"{:?}\",\"to\":\"{:?}\",\"phase\":\"{:?}\",\"rewritten\":{}}}",
|
||||
"{{\"ok\":true,\"from\":\"{:?}\",\"to\":\"{:?}\",\"phase\":\"{:?}\",\"\
|
||||
rewritten\":{}}}",
|
||||
outcome.from_tier,
|
||||
outcome.to_tier,
|
||||
outcome.phase,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@ fn extract_config_flags(content: &str) -> Vec<String> {
|
|||
let eq_pos = rest.find('=')?;
|
||||
let id = rest[..eq_pos].trim();
|
||||
let val = rest[eq_pos + 1..].trim();
|
||||
if val.starts_with("true") && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !id.is_empty() {
|
||||
if val.starts_with("true")
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
&& !id.is_empty()
|
||||
{
|
||||
Some(id.to_string())
|
||||
} else {
|
||||
None
|
||||
|
|
@ -74,7 +77,10 @@ fn extract_domain_origin(content: &str) -> Option<(String, String)> {
|
|||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 { close = open + i; break; }
|
||||
if depth == 0 {
|
||||
close = open + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -84,16 +90,23 @@ fn extract_domain_origin(content: &str) -> Option<(String, String)> {
|
|||
let id = inner.lines().find_map(|l| {
|
||||
let t = l.trim();
|
||||
let rest = t.strip_prefix("id")?.trim_start_matches([' ', '=']).trim();
|
||||
rest.strip_prefix('"')?.split('"').next().map(|s| {
|
||||
s.replace("-framework", "").replace("-domain", "")
|
||||
})
|
||||
rest.strip_prefix('"')?
|
||||
.split('"')
|
||||
.next()
|
||||
.map(|s| s.replace("-framework", "").replace("-domain", ""))
|
||||
})?;
|
||||
|
||||
let path = inner.lines().find_map(|l| {
|
||||
let t = l.trim();
|
||||
let rest = t.strip_prefix("path")?.trim_start_matches([' ', '=']).trim();
|
||||
rest.strip_prefix('"')?.split('"').next().map(String::from)
|
||||
}).unwrap_or_default();
|
||||
let path = inner
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
let t = l.trim();
|
||||
let rest = t
|
||||
.strip_prefix("path")?
|
||||
.trim_start_matches([' ', '='])
|
||||
.trim();
|
||||
rest.strip_prefix('"')?.split('"').next().map(String::from)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Some((id, path))
|
||||
}
|
||||
|
|
@ -107,7 +120,11 @@ pub fn project_domains(root: &Path) -> Vec<DomainDecl> {
|
|||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
for id in extract_config_flags(&content) {
|
||||
if !domains.iter().any(|d| d.id == id) {
|
||||
domains.push(DomainDecl { id, kind: DomainDeclKind::ConfigFlag, path: String::new() });
|
||||
domains.push(DomainDecl {
|
||||
id,
|
||||
kind: DomainDeclKind::ConfigFlag,
|
||||
path: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +134,11 @@ pub fn project_domains(root: &Path) -> Vec<DomainDecl> {
|
|||
if let Ok(content) = std::fs::read_to_string(&manifest_path) {
|
||||
if let Some((id, path)) = extract_domain_origin(&content) {
|
||||
if !domains.iter().any(|d| d.id == id) {
|
||||
domains.push(DomainDecl { id, kind: DomainDeclKind::DomainOrigin, path });
|
||||
domains.push(DomainDecl {
|
||||
id,
|
||||
kind: DomainDeclKind::DomainOrigin,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,7 +157,11 @@ pub fn project_domains(root: &Path) -> Vec<DomainDecl> {
|
|||
.trim_end_matches(".ontoref")
|
||||
.to_string();
|
||||
if !domains.iter().any(|d| d.id == id) {
|
||||
domains.push(DomainDecl { id, kind: DomainDeclKind::ImplDir, path: String::new() });
|
||||
domains.push(DomainDecl {
|
||||
id,
|
||||
kind: DomainDeclKind::ImplDir,
|
||||
path: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,16 +177,20 @@ pub fn consumers_of(registry: &ProjectRegistry, domain_id: &str) -> Vec<DomainCo
|
|||
.into_iter()
|
||||
.filter_map(|ctx| {
|
||||
let domains = project_domains(&ctx.root);
|
||||
domains.into_iter().find(|d| d.id == domain_id).map(|d| DomainConsumer {
|
||||
slug: ctx.slug.clone(),
|
||||
root: ctx.root.to_string_lossy().into_owned(),
|
||||
kind: d.kind,
|
||||
})
|
||||
domains
|
||||
.into_iter()
|
||||
.find(|d| d.id == domain_id)
|
||||
.map(|d| DomainConsumer {
|
||||
slug: ctx.slug.clone(),
|
||||
root: ctx.root.to_string_lossy().into_owned(),
|
||||
kind: d.kind,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Registered domain ids (directories under $ONTOREF_ROOT/domains/ with domain.ncl).
|
||||
/// Registered domain ids (directories under $ONTOREF_ROOT/domains/ with
|
||||
/// domain.ncl).
|
||||
pub fn registered_domain_ids(ontoref_root: &Path) -> Vec<String> {
|
||||
let domains_dir = ontoref_root.join("domains");
|
||||
std::fs::read_dir(&domains_dir)
|
||||
|
|
@ -183,15 +212,21 @@ pub struct DomainMeta {
|
|||
}
|
||||
|
||||
fn extract_ncl_string_field(content: &str, field: &str) -> String {
|
||||
content.lines().find_map(|l| {
|
||||
let t = l.trim();
|
||||
let rest = t.strip_prefix(field)?.trim_start_matches([' ', '=']).trim();
|
||||
rest.strip_prefix('"')?.split('"').next().map(String::from)
|
||||
}).unwrap_or_default()
|
||||
content
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
let t = l.trim();
|
||||
let rest = t.strip_prefix(field)?.trim_start_matches([' ', '=']).trim();
|
||||
rest.strip_prefix('"')?.split('"').next().map(String::from)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn domain_meta(ontoref_root: &Path, domain_id: &str) -> Option<DomainMeta> {
|
||||
let ncl = ontoref_root.join("domains").join(domain_id).join("domain.ncl");
|
||||
let ncl = ontoref_root
|
||||
.join("domains")
|
||||
.join(domain_id)
|
||||
.join("domain.ncl");
|
||||
let content = std::fs::read_to_string(&ncl).ok()?;
|
||||
Some(DomainMeta {
|
||||
id: domain_id.to_string(),
|
||||
|
|
@ -207,7 +242,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn extract_config_flags_finds_domain_declarations() {
|
||||
let content = "ops = { tier = 'Tier0 },\n domain_provisioning = true,\n domain_rustelo = true,\n other = false,";
|
||||
let content = "ops = { tier = 'Tier0 },\n domain_provisioning = true,\n domain_rustelo \
|
||||
= true,\n other = false,";
|
||||
let flags = extract_config_flags(content);
|
||||
assert!(flags.contains(&"provisioning".to_string()));
|
||||
assert!(flags.contains(&"rustelo".to_string()));
|
||||
|
|
|
|||
|
|
@ -184,7 +184,8 @@ impl FederatedQuery {
|
|||
if ctx.push_only {
|
||||
return;
|
||||
}
|
||||
let conn_path = ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/connections.ncl");
|
||||
let conn_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/connections.ncl");
|
||||
if !conn_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -343,7 +344,8 @@ pub async fn validate_connections(registry: &Arc<ProjectRegistry>, slug: &str) -
|
|||
let Some(ctx) = registry.get(slug) else {
|
||||
return vec![format!("slug '{slug}' not found in registry")];
|
||||
};
|
||||
let conn_path = ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/connections.ncl");
|
||||
let conn_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/connections.ncl");
|
||||
if !conn_path.exists() {
|
||||
return vec![];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
//! Constellation-memory GraphQL output types with single-source cross-surface equivalence.
|
||||
//! Constellation-memory GraphQL output types with single-source cross-surface
|
||||
//! equivalence.
|
||||
//!
|
||||
//! Re-exports from [`crate::memory_types`] — the canonical single definition that uses
|
||||
//! `#[cfg_attr(feature = "graphql", derive(SimpleObject))]` so the types compile on every
|
||||
//! surface (HTTP, MCP, GraphQL) without duplication. G-4 coherence tests live here where
|
||||
//! they can reference the `async_graphql` SDL generation logic.
|
||||
//! Re-exports from [`crate::memory_types`] — the canonical single definition
|
||||
//! that uses `#[cfg_attr(feature = "graphql", derive(SimpleObject))]` so the
|
||||
//! types compile on every surface (HTTP, MCP, GraphQL) without duplication. G-4
|
||||
//! coherence tests live here where they can reference the `async_graphql` SDL
|
||||
//! generation logic.
|
||||
|
||||
pub use crate::memory_types::{ChronicleEntry, Criterion, PanelSpec, RoadmapItem};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn serde_keys<T: serde::Serialize>(value: &T) -> Vec<String> {
|
||||
let v = serde_json::to_value(value).expect("entity must serialize");
|
||||
let mut keys: Vec<String> = v
|
||||
|
|
@ -91,7 +94,14 @@ mod tests {
|
|||
surfaces: vec!["Ui".into()],
|
||||
actors: vec!["Developer".into()],
|
||||
}),
|
||||
sorted(&["id", "title", "kind", "data_source_op", "surfaces", "actors"]),
|
||||
sorted(&[
|
||||
"id",
|
||||
"title",
|
||||
"kind",
|
||||
"data_source_op",
|
||||
"surfaces",
|
||||
"actors"
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -888,15 +888,19 @@ impl QueryRoot {
|
|||
slug: Option<String>,
|
||||
) -> async_graphql::Result<Vec<DomainDeclGql>> {
|
||||
let state = ctx.data::<AppState>()?;
|
||||
let pctx = slug.as_deref()
|
||||
let pctx = slug
|
||||
.as_deref()
|
||||
.and_then(|s| state.registry.get(s))
|
||||
.unwrap_or_else(|| state.registry.primary());
|
||||
let decls = crate::domain::project_domains(&pctx.root);
|
||||
Ok(decls.into_iter().map(|d| DomainDeclGql {
|
||||
id: d.id,
|
||||
kind: format!("{:?}", d.kind),
|
||||
path: d.path,
|
||||
}).collect())
|
||||
Ok(decls
|
||||
.into_iter()
|
||||
.map(|d| DomainDeclGql {
|
||||
id: d.id,
|
||||
kind: format!("{:?}", d.kind),
|
||||
path: d.path,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// All registered projects implementing a given domain.
|
||||
|
|
@ -907,11 +911,14 @@ impl QueryRoot {
|
|||
) -> async_graphql::Result<Vec<DomainConsumerGql>> {
|
||||
let state = ctx.data::<AppState>()?;
|
||||
let consumers = crate::domain::consumers_of(&state.registry, &domain_id);
|
||||
Ok(consumers.into_iter().map(|c| DomainConsumerGql {
|
||||
slug: c.slug,
|
||||
root: c.root,
|
||||
kind: format!("{:?}", c.kind),
|
||||
}).collect())
|
||||
Ok(consumers
|
||||
.into_iter()
|
||||
.map(|c| DomainConsumerGql {
|
||||
slug: c.slug,
|
||||
root: c.root,
|
||||
kind: format!("{:?}", c.kind),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Domain metadata + all its consumers.
|
||||
|
|
@ -933,11 +940,14 @@ impl QueryRoot {
|
|||
name: meta.name,
|
||||
description: meta.description,
|
||||
schema_cmd: meta.schema_cmd,
|
||||
consumers: consumers.into_iter().map(|c| DomainConsumerGql {
|
||||
slug: c.slug,
|
||||
root: c.root,
|
||||
kind: format!("{:?}", c.kind),
|
||||
}).collect(),
|
||||
consumers: consumers
|
||||
.into_iter()
|
||||
.map(|c| DomainConsumerGql {
|
||||
slug: c.slug,
|
||||
root: c.root,
|
||||
kind: format!("{:?}", c.kind),
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -948,7 +958,9 @@ impl QueryRoot {
|
|||
slug: Option<String>,
|
||||
) -> async_graphql::Result<TierStatusGql> {
|
||||
let state = ctx.data::<AppState>()?;
|
||||
let effective = slug.as_deref().unwrap_or_else(|| state.registry.primary_slug());
|
||||
let effective = slug
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| state.registry.primary_slug());
|
||||
let pctx = state
|
||||
.registry
|
||||
.get(effective)
|
||||
|
|
@ -979,7 +991,9 @@ impl QueryRoot {
|
|||
slug: Option<String>,
|
||||
) -> async_graphql::Result<TierProjectionGql> {
|
||||
let state = ctx.data::<AppState>()?;
|
||||
let effective = slug.as_deref().unwrap_or_else(|| state.registry.primary_slug());
|
||||
let effective = slug
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| state.registry.primary_slug());
|
||||
let pctx = state
|
||||
.registry
|
||||
.get(effective)
|
||||
|
|
@ -1002,7 +1016,8 @@ impl QueryRoot {
|
|||
})
|
||||
}
|
||||
|
||||
/// Chronicle timeline entries woven from Accepted ADRs and protocol migrations.
|
||||
/// Chronicle timeline entries woven from Accepted ADRs and protocol
|
||||
/// migrations.
|
||||
async fn chronicle(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
|
|
@ -1053,13 +1068,10 @@ impl QueryRoot {
|
|||
) -> async_graphql::Result<Vec<Criterion>> {
|
||||
let state = ctx.data::<AppState>()?;
|
||||
let (root, cache, import_path) = project_ctx(state, slug.as_deref());
|
||||
Ok(crate::memory::criteria_list(
|
||||
&root,
|
||||
&cache,
|
||||
import_path.as_deref(),
|
||||
kind.as_deref(),
|
||||
Ok(
|
||||
crate::memory::criteria_list(&root, &cache, import_path.as_deref(), kind.as_deref())
|
||||
.await,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Resolved panel specs from the presentation directory (ADR-053).
|
||||
|
|
@ -1071,13 +1083,10 @@ impl QueryRoot {
|
|||
) -> async_graphql::Result<Vec<PanelSpec>> {
|
||||
let state = ctx.data::<AppState>()?;
|
||||
let (root, cache, import_path) = project_ctx(state, slug.as_deref());
|
||||
Ok(crate::memory::panels_list(
|
||||
&root,
|
||||
&cache,
|
||||
import_path.as_deref(),
|
||||
kind.as_deref(),
|
||||
Ok(
|
||||
crate::memory::panels_list(&root, &cache, import_path.as_deref(), kind.as_deref())
|
||||
.await,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Combined config.ncl + card.ncl for a project.
|
||||
|
|
|
|||
|
|
@ -175,10 +175,10 @@ pub fn sign_payload_for_actor(
|
|||
|
||||
/// Verify a payload signature given a hex-encoded public key (32 bytes) and a
|
||||
/// hex-encoded Ed25519 signature (64 bytes). Returns `false` for any malformed
|
||||
/// input rather than erroring — the caller treats verification as a boolean gate.
|
||||
/// input rather than erroring — the caller treats verification as a boolean
|
||||
/// gate.
|
||||
pub fn verify_payload_hex(public_key_hex: &str, payload: &[u8], signature_hex: &str) -> bool {
|
||||
let (Some(pk_bytes), Some(sig_bytes)) =
|
||||
(hex_decode(public_key_hex), hex_decode(signature_hex))
|
||||
let (Some(pk_bytes), Some(sig_bytes)) = (hex_decode(public_key_hex), hex_decode(signature_hex))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
|
@ -212,7 +212,8 @@ pub fn register_actor_in_ncl(
|
|||
validate_actor_id(actor_id)?;
|
||||
let pk_hex = public_key_hex(public_key);
|
||||
let entry = format!(
|
||||
" {{ id = \"{actor_id}\", public_key = \"{pk_hex}\", created_at = \"{created_at}\" }},\n"
|
||||
" {{ id = \"{actor_id}\", public_key = \"{pk_hex}\", created_at = \"{created_at}\" \
|
||||
}},\n"
|
||||
);
|
||||
|
||||
if !actors_ncl.exists() {
|
||||
|
|
|
|||
|
|
@ -19,30 +19,30 @@ pub mod config_coherence;
|
|||
pub mod domain;
|
||||
pub mod error;
|
||||
pub mod federation;
|
||||
pub mod keypair;
|
||||
#[cfg(feature = "nats")]
|
||||
pub mod nats_sync;
|
||||
pub mod ops_surface;
|
||||
#[cfg(feature = "substrate")]
|
||||
pub mod rotation;
|
||||
#[cfg(feature = "graphql")]
|
||||
pub mod graphql;
|
||||
pub mod keypair;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod mcp;
|
||||
#[cfg(feature = "nats")]
|
||||
pub mod nats;
|
||||
pub mod notifications;
|
||||
pub mod positioning;
|
||||
pub mod registry;
|
||||
pub mod memory;
|
||||
pub mod memory_types;
|
||||
pub mod views;
|
||||
#[cfg(feature = "nats")]
|
||||
pub mod nats;
|
||||
#[cfg(feature = "nats")]
|
||||
pub mod nats_sync;
|
||||
pub mod notifications;
|
||||
pub mod ops_surface;
|
||||
pub mod positioning;
|
||||
pub mod registry;
|
||||
#[cfg(feature = "substrate")]
|
||||
pub mod rotation;
|
||||
pub mod search;
|
||||
#[cfg(feature = "db")]
|
||||
pub mod seed;
|
||||
#[cfg(feature = "ui")]
|
||||
pub mod session;
|
||||
pub mod sync;
|
||||
pub mod views;
|
||||
// ADR-033 tier/transition/reload primitives — moved to ontoref-ops so the
|
||||
// catalogued op handler can reuse them. Re-exported here for backward
|
||||
// compatibility with consumers that imported via ontoref_daemon::*.
|
||||
|
|
|
|||
|
|
@ -320,14 +320,14 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
actor: Option<String>,
|
||||
|
||||
/// Sign an arbitrary payload (e.g. a substrate-view adjustment digest, ADR-046
|
||||
/// C2) with the actor's Ed25519 key, print `{actor, payload, signature,
|
||||
/// public_key}` as JSON, then exit. Requires --actor.
|
||||
/// Sign an arbitrary payload (e.g. a substrate-view adjustment digest,
|
||||
/// ADR-046 C2) with the actor's Ed25519 key, print `{actor, payload,
|
||||
/// signature, public_key}` as JSON, then exit. Requires --actor.
|
||||
#[arg(long, value_name = "PAYLOAD", requires = "actor")]
|
||||
sign_payload: Option<String>,
|
||||
|
||||
/// Verify a payload signature. Requires --actor-pubkey and --signature. Prints
|
||||
/// `{valid}` and exits 0 (valid) or 4 (invalid).
|
||||
/// Verify a payload signature. Requires --actor-pubkey and --signature.
|
||||
/// Prints `{valid}` and exits 0 (valid) or 4 (invalid).
|
||||
#[arg(long, value_name = "PAYLOAD", requires_all = ["actor_pubkey", "signature"])]
|
||||
verify_payload: Option<String>,
|
||||
|
||||
|
|
@ -516,9 +516,7 @@ fn apply_keys_overlay(
|
|||
fn init_tera(
|
||||
templates_dir: Option<&std::path::Path>,
|
||||
) -> Option<Arc<tokio::sync::RwLock<ui::TeraEnv>>> {
|
||||
use ui::{
|
||||
AssetUrls, Brand, BrandKind, SessionOptions, TeraOptions, ThemeOptions, UiFeatures,
|
||||
};
|
||||
use ui::{AssetUrls, Brand, BrandKind, SessionOptions, TeraOptions, ThemeOptions, UiFeatures};
|
||||
|
||||
let tdir = templates_dir?;
|
||||
let cache_buster = std::time::SystemTime::now()
|
||||
|
|
@ -681,7 +679,9 @@ async fn main() {
|
|||
|
||||
let Some(classification) = ontoref_ontology_content::OntologyType::parse(type_tag) else {
|
||||
eprintln!(
|
||||
"extract-ontology: unknown --extract-type-tag '{type_tag}'; expected Type1Protocol | Type2aFederable | Type2bMandatoryDecentralized | Type3ProjectSpecific | Type4Private"
|
||||
"extract-ontology: unknown --extract-type-tag '{type_tag}'; expected \
|
||||
Type1Protocol | Type2aFederable | Type2bMandatoryDecentralized | \
|
||||
Type3ProjectSpecific | Type4Private"
|
||||
);
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
|
@ -756,8 +756,11 @@ async fn main() {
|
|||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
match ontoref_daemon::keypair::sign_payload_for_actor(actor_id, &key_dir, payload.as_bytes())
|
||||
{
|
||||
match ontoref_daemon::keypair::sign_payload_for_actor(
|
||||
actor_id,
|
||||
&key_dir,
|
||||
payload.as_bytes(),
|
||||
) {
|
||||
Ok((signature, public_key)) => {
|
||||
println!(
|
||||
"{}",
|
||||
|
|
@ -791,8 +794,8 @@ async fn main() {
|
|||
// FIPS-204 plug-in slot: recognized scheme, backend not built into
|
||||
// this binary (would require the `pqc` feature + a validated module).
|
||||
eprintln!(
|
||||
"verify-payload: scheme 'ml-dsa-65' recognized but not built — \
|
||||
rebuild with the pqc feature"
|
||||
"verify-payload: scheme 'ml-dsa-65' recognized but not built — rebuild with \
|
||||
the pqc feature"
|
||||
);
|
||||
std::process::exit(5);
|
||||
}
|
||||
|
|
@ -1229,8 +1232,8 @@ async fn main() {
|
|||
}
|
||||
} else {
|
||||
warn!(
|
||||
"ONTOREF_SIGNING_KEY_FILE not set — generating ephemeral Ed25519 key; \
|
||||
JWKS will change on every restart"
|
||||
"ONTOREF_SIGNING_KEY_FILE not set — generating ephemeral Ed25519 key; JWKS will \
|
||||
change on every restart"
|
||||
);
|
||||
Arc::new(auth::TokenIssuer::generate(issuer_id))
|
||||
}
|
||||
|
|
@ -1380,11 +1383,7 @@ async fn main() {
|
|||
// the guard via `ProjectContext::apply_ops_reload`; rejected reloads
|
||||
// log a Block warning and the in-memory ops value is retained.
|
||||
let _ops_watcher = {
|
||||
let ops_config_path = registry
|
||||
.primary()
|
||||
.root
|
||||
.join(".ontoref")
|
||||
.join("config.ncl");
|
||||
let ops_config_path = registry.primary().root.join(".ontoref").join("config.ncl");
|
||||
if ops_config_path.exists() {
|
||||
let primary_ref = registry.primary();
|
||||
match ontoref_daemon::watcher::OpsWatcher::start(
|
||||
|
|
@ -1461,8 +1460,8 @@ async fn main() {
|
|||
.layer(axum::middleware::from_fn(
|
||||
|req: axum::extract::Request, next: axum::middleware::Next| async move {
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
let resp = next.run(req).await;
|
||||
let uri = req.uri().clone();
|
||||
let resp = next.run(req).await;
|
||||
let status = resp.status().as_u16();
|
||||
if status >= 500 {
|
||||
tracing::error!(method = %method, uri = %uri, status = status, "5xx");
|
||||
|
|
@ -1954,8 +1953,8 @@ fn extract_ontology_now(
|
|||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|e| format!("parsing nickel JSON: {e}"))?;
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).map_err(|e| format!("parsing nickel JSON: {e}"))?;
|
||||
|
||||
// Deterministic signing key derived from the ontology name — keeps
|
||||
// re-runs idempotent without requiring per-extraction keypair setup.
|
||||
|
|
@ -1973,9 +1972,8 @@ fn extract_ontology_now(
|
|||
.map_err(|e| format!("creating {}: {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
let stats =
|
||||
ontoref_ontology_content::extract_core_to_oplog(&json, &oplog_path, &signing_key)
|
||||
.map_err(|e| format!("extraction: {e}"))?;
|
||||
let stats = ontoref_ontology_content::extract_core_to_oplog(&json, &oplog_path, &signing_key)
|
||||
.map_err(|e| format!("extraction: {e}"))?;
|
||||
|
||||
let refs_path = ontoref_ontology::layout::resolve_section(project_root, "ontology/_refs.ncl");
|
||||
ontoref_ontology_content::write_ontology_refs(
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@ struct GetItemInput {
|
|||
|
||||
#[derive(Deserialize, JsonSchema, Default)]
|
||||
struct DisciplineInput {
|
||||
/// Discipline (work area) to mount — e.g. `coding`, `positioning`, `difusion`.
|
||||
/// Discipline (work area) to mount — e.g. `coding`, `positioning`,
|
||||
/// `difusion`.
|
||||
discipline: String,
|
||||
/// Project slug. Omit to use the default project.
|
||||
project: Option<String>,
|
||||
|
|
@ -602,11 +603,12 @@ impl AsyncTool<OntoreServer> for ViewListTool {
|
|||
|
||||
#[ontoref_derive::onto_mcp_tool(
|
||||
name = "ontoref_view_mount",
|
||||
description = "Mount a discipline's substrate view: the composed recipe (declared prior \
|
||||
plus learned tuning) of which levels to load, ordered by salience, with \
|
||||
out-of-repo levels resolved by witness. The agent-facing provisioning surface.",
|
||||
description = "Mount a discipline's substrate view: the composed recipe (declared prior plus \
|
||||
learned tuning) of which levels to load, ordered by salience, with out-of-repo \
|
||||
levels resolved by witness. The agent-facing provisioning surface.",
|
||||
category = "discovery",
|
||||
params = "discipline:string:required:Discipline/work area (e.g. coding, positioning); project:string:optional:Project slug"
|
||||
params = "discipline:string:required:Discipline/work area (e.g. coding, positioning); \
|
||||
project:string:optional:Project slug"
|
||||
)]
|
||||
struct ViewMountTool;
|
||||
|
||||
|
|
@ -620,7 +622,11 @@ impl ToolBase for ViewMountTool {
|
|||
}
|
||||
|
||||
fn description() -> Option<Cow<'static, str>> {
|
||||
Some("Mount a discipline's substrate view — composed recipe, prior plus learned tuning (ADR-046).".into())
|
||||
Some(
|
||||
"Mount a discipline's substrate view — composed recipe, prior plus learned tuning \
|
||||
(ADR-046)."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn output_schema() -> Option<Arc<JsonObject>> {
|
||||
|
|
@ -691,7 +697,8 @@ impl AsyncTool<OntoreServer> for DescribeProjectTool {
|
|||
) -> Result<serde_json::Value, ToolError> {
|
||||
debug!(tool = "describe_project", project = ?param.project);
|
||||
let ctx = service.project_ctx(param.project.as_deref());
|
||||
let manifest_path = ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/manifest.ncl");
|
||||
let manifest_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/manifest.ncl");
|
||||
|
||||
let manifest = if manifest_path.exists() {
|
||||
match ctx
|
||||
|
|
@ -1001,7 +1008,8 @@ impl AsyncTool<OntoreServer> for GetOntologyExtensionTool {
|
|||
)));
|
||||
}
|
||||
|
||||
let path = ontoref_ontology::layout::resolve_section(&ctx.root, &format!("ontology/{file}"));
|
||||
let path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, &format!("ontology/{file}"));
|
||||
if !path.exists() {
|
||||
return Err(ToolError(format!(
|
||||
"ontology extension '{}' not found",
|
||||
|
|
@ -1203,7 +1211,8 @@ impl AsyncTool<OntoreServer> for GetBacklogTool {
|
|||
) -> Result<serde_json::Value, ToolError> {
|
||||
debug!(tool = "get_backlog", project = ?param.project, status = ?param.status);
|
||||
let ctx = service.project_ctx(param.project.as_deref());
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");
|
||||
|
||||
if !backlog_path.exists() {
|
||||
return Ok(serde_json::json!({ "items": [] }));
|
||||
|
|
@ -1555,7 +1564,8 @@ impl AsyncTool<OntoreServer> for ProjectStatusTool {
|
|||
let recent_notifications = service.state.notifications.all_recent().len();
|
||||
|
||||
// Backlog stats
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");
|
||||
let backlog_stats = if backlog_path.exists() {
|
||||
match ctx
|
||||
.cache
|
||||
|
|
@ -1585,25 +1595,30 @@ impl AsyncTool<OntoreServer> for ProjectStatusTool {
|
|||
};
|
||||
|
||||
// ADR count
|
||||
let adr_count = std::fs::read_dir(ontoref_ontology::layout::resolve_section(&ctx.root, "adrs"))
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let adr_count =
|
||||
std::fs::read_dir(ontoref_ontology::layout::resolve_section(&ctx.root, "adrs"))
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Mode count
|
||||
let mode_count = std::fs::read_dir(ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/modes"))
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mode_count = std::fs::read_dir(ontoref_ontology::layout::resolve_section(
|
||||
&ctx.root,
|
||||
"reflection/modes",
|
||||
))
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ncl"))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Manifest
|
||||
let manifest_path = ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/manifest.ncl");
|
||||
let manifest_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "ontology/manifest.ncl");
|
||||
let manifest = if manifest_path.exists() {
|
||||
match ctx
|
||||
.cache
|
||||
|
|
@ -1732,7 +1747,8 @@ impl AsyncTool<OntoreServer> for BacklogOpTool {
|
|||
) -> Result<serde_json::Value, ToolError> {
|
||||
debug!(tool = "backlog", operation = %param.operation, project = ?param.project);
|
||||
let ctx = service.project_ctx(param.project.as_deref());
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/backlog.ncl");
|
||||
|
||||
if !backlog_path.exists() {
|
||||
return Err(ToolError(format!(
|
||||
|
|
@ -2816,7 +2832,8 @@ impl AsyncTool<OntoreServer> for BookmarkListTool {
|
|||
) -> Result<serde_json::Value, ToolError> {
|
||||
debug!(tool = "bookmark_list", project = ?param.project);
|
||||
let ctx = service.project_ctx(param.project.as_deref());
|
||||
let bm_path = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/search_bookmarks.ncl");
|
||||
let bm_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/search_bookmarks.ncl");
|
||||
|
||||
if !bm_path.exists() {
|
||||
return Ok(serde_json::json!({ "entries": [], "count": 0 }));
|
||||
|
|
@ -2902,7 +2919,8 @@ impl AsyncTool<OntoreServer> for BookmarkAddTool {
|
|||
) -> Result<serde_json::Value, ToolError> {
|
||||
debug!(tool = "bookmark_add", project = ?param.project, node_id = %param.node_id);
|
||||
let ctx = service.project_ctx(param.project.as_deref());
|
||||
let bm_path = ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/search_bookmarks.ncl");
|
||||
let bm_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx.root, "reflection/search_bookmarks.ncl");
|
||||
|
||||
if !bm_path.exists() {
|
||||
return Err(ToolError(format!(
|
||||
|
|
@ -3537,12 +3555,7 @@ impl AsyncTool<OntoreServer> for PositioningGetTool {
|
|||
let live_value_props = p
|
||||
.value_props()
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
matches!(
|
||||
v.status,
|
||||
ontoref_ontology::types::PositioningStatus::Live
|
||||
)
|
||||
})
|
||||
.filter(|v| matches!(v.status, ontoref_ontology::types::PositioningStatus::Live))
|
||||
.count();
|
||||
Ok(serde_json::json!({
|
||||
"audiences": p.audiences(),
|
||||
|
|
@ -3582,8 +3595,8 @@ impl ToolBase for PositioningGetValuePropTool {
|
|||
|
||||
fn description() -> Option<Cow<'static, str>> {
|
||||
Some(
|
||||
"Single value-prop by id with audiences expanded inline. Saves a second tool call: \
|
||||
an agent reasoning about an audience for a claim gets the resolved Audience records \
|
||||
"Single value-prop by id with audiences expanded inline. Saves a second tool call: an \
|
||||
agent reasoning about an audience for a claim gets the resolved Audience records \
|
||||
alongside the value-prop in one response."
|
||||
.into(),
|
||||
)
|
||||
|
|
@ -3643,9 +3656,10 @@ impl ToolBase for PositioningAuditTool {
|
|||
|
||||
fn description() -> Option<Cow<'static, str>> {
|
||||
Some(
|
||||
"Cross-reference evidence_adrs against ADR status across all value-props. Drift kinds: \
|
||||
Missing (cited file absent), Superseded (with optional by= replacement), Deprecated, \
|
||||
Proposed (claim cites uncommitted decision). Returns { checked_value_props, drift }."
|
||||
"Cross-reference evidence_adrs against ADR status across all value-props. Drift \
|
||||
kinds: Missing (cited file absent), Superseded (with optional by= replacement), \
|
||||
Deprecated, Proposed (claim cites uncommitted decision). Returns { \
|
||||
checked_value_props, drift }."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
|
@ -3686,25 +3700,34 @@ impl ToolBase for DomainListTool {
|
|||
type Output = serde_json::Value;
|
||||
type Error = ToolError;
|
||||
|
||||
fn name() -> Cow<'static, str> { "ontoref_domain_list".into() }
|
||||
fn name() -> Cow<'static, str> {
|
||||
"ontoref_domain_list".into()
|
||||
}
|
||||
|
||||
fn description() -> Option<Cow<'static, str>> {
|
||||
Some(
|
||||
"Return all domains the project implements as { id, kind, path }[]. \
|
||||
kind: config_flag (domain_X=true in config.ncl), domain_origin (manifest.ncl), \
|
||||
or impl_dir (.{domain}.ontoref/ directory). \
|
||||
Use to navigate from a consumer project to its domain source."
|
||||
"Return all domains the project implements as { id, kind, path }[]. kind: config_flag \
|
||||
(domain_X=true in config.ncl), domain_origin (manifest.ncl), or impl_dir \
|
||||
(.{domain}.ontoref/ directory). Use to navigate from a consumer project to its \
|
||||
domain source."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn output_schema() -> Option<Arc<JsonObject>> { None }
|
||||
fn output_schema() -> Option<Arc<JsonObject>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncTool<OntoreServer> for DomainListTool {
|
||||
async fn invoke(service: &OntoreServer, param: ProjectParam) -> Result<serde_json::Value, ToolError> {
|
||||
async fn invoke(
|
||||
service: &OntoreServer,
|
||||
param: ProjectParam,
|
||||
) -> Result<serde_json::Value, ToolError> {
|
||||
debug!(tool = "domain_list", project = ?param.project);
|
||||
let pctx = param.project.as_deref()
|
||||
let pctx = param
|
||||
.project
|
||||
.as_deref()
|
||||
.and_then(|s| service.state.registry.get(s))
|
||||
.unwrap_or_else(|| service.state.registry.primary());
|
||||
let decls = crate::domain::project_domains(&pctx.root);
|
||||
|
|
@ -3722,7 +3745,8 @@ struct DomainIdInput {
|
|||
|
||||
#[ontoref_derive::onto_mcp_tool(
|
||||
name = "ontoref_domain_consumers",
|
||||
description = "List all registered projects that implement a given domain (domain → consumers direction).",
|
||||
description = "List all registered projects that implement a given domain (domain → consumers \
|
||||
direction).",
|
||||
category = "domain",
|
||||
params = "domain_id:string:required:Domain id (e.g. rustelo, provisioning)"
|
||||
)]
|
||||
|
|
@ -3733,22 +3757,29 @@ impl ToolBase for DomainConsumersTool {
|
|||
type Output = serde_json::Value;
|
||||
type Error = ToolError;
|
||||
|
||||
fn name() -> Cow<'static, str> { "ontoref_domain_consumers".into() }
|
||||
fn name() -> Cow<'static, str> {
|
||||
"ontoref_domain_consumers".into()
|
||||
}
|
||||
|
||||
fn description() -> Option<Cow<'static, str>> {
|
||||
Some(
|
||||
"Return all registered projects implementing the given domain id. \
|
||||
Result: { domain_id, consumers: [{ slug, root, kind }] }. \
|
||||
Use to navigate from a domain project to all its consumer implementations."
|
||||
"Return all registered projects implementing the given domain id. Result: { \
|
||||
domain_id, consumers: [{ slug, root, kind }] }. Use to navigate from a domain \
|
||||
project to all its consumer implementations."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn output_schema() -> Option<Arc<JsonObject>> { None }
|
||||
fn output_schema() -> Option<Arc<JsonObject>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncTool<OntoreServer> for DomainConsumersTool {
|
||||
async fn invoke(service: &OntoreServer, param: DomainIdInput) -> Result<serde_json::Value, ToolError> {
|
||||
async fn invoke(
|
||||
service: &OntoreServer,
|
||||
param: DomainIdInput,
|
||||
) -> Result<serde_json::Value, ToolError> {
|
||||
debug!(tool = "domain_consumers", id = %param.domain_id);
|
||||
let consumers = crate::domain::consumers_of(&service.state.registry, ¶m.domain_id);
|
||||
Ok(serde_json::json!({ "domain_id": param.domain_id, "consumers": consumers }))
|
||||
|
|
@ -3770,21 +3801,28 @@ impl ToolBase for DomainShowTool {
|
|||
type Output = serde_json::Value;
|
||||
type Error = ToolError;
|
||||
|
||||
fn name() -> Cow<'static, str> { "ontoref_domain_show".into() }
|
||||
fn name() -> Cow<'static, str> {
|
||||
"ontoref_domain_show".into()
|
||||
}
|
||||
|
||||
fn description() -> Option<Cow<'static, str>> {
|
||||
Some(
|
||||
"Return domain metadata (name, description, schema_cmd) and all registered \
|
||||
consumer projects. Combines the domain definition with the live consumer graph."
|
||||
"Return domain metadata (name, description, schema_cmd) and all registered consumer \
|
||||
projects. Combines the domain definition with the live consumer graph."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn output_schema() -> Option<Arc<JsonObject>> { None }
|
||||
fn output_schema() -> Option<Arc<JsonObject>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncTool<OntoreServer> for DomainShowTool {
|
||||
async fn invoke(service: &OntoreServer, param: DomainIdInput) -> Result<serde_json::Value, ToolError> {
|
||||
async fn invoke(
|
||||
service: &OntoreServer,
|
||||
param: DomainIdInput,
|
||||
) -> Result<serde_json::Value, ToolError> {
|
||||
debug!(tool = "domain_show", id = %param.domain_id);
|
||||
let ontoref_root = std::env::var("ONTOREF_ROOT")
|
||||
.map(std::path::PathBuf::from)
|
||||
|
|
@ -3971,9 +4009,8 @@ impl AsyncTool<OntoreServer> for TierNextMcpTool {
|
|||
name = "ontoref_chronicle",
|
||||
description = "Chronicle timeline: Accepted ADRs and protocol migrations, newest first.",
|
||||
category = "memory",
|
||||
params = "project:string:optional:Project slug; \
|
||||
since:string:optional:ISO date lower bound (e.g. 2026-01-01); \
|
||||
kind:string:optional:adr|migration; \
|
||||
params = "project:string:optional:Project slug; since:string:optional:ISO date lower bound \
|
||||
(e.g. 2026-01-01); kind:string:optional:adr|migration; \
|
||||
domain:string:optional:Domain filter"
|
||||
)]
|
||||
struct ChronicleListTool;
|
||||
|
|
@ -4069,8 +4106,7 @@ impl AsyncTool<OntoreServer> for RoadmapListTool {
|
|||
name = "ontoref_criteria",
|
||||
description = "Criteria promoted to catalog validators (ADR-049/050).",
|
||||
category = "memory",
|
||||
params = "project:string:optional:Project slug; \
|
||||
kind:string:optional:normative"
|
||||
params = "project:string:optional:Project slug; kind:string:optional:normative"
|
||||
)]
|
||||
struct CriteriaListTool;
|
||||
|
||||
|
|
@ -4106,10 +4142,8 @@ impl AsyncTool<OntoreServer> for CriteriaListTool {
|
|||
param.kind.as_deref(),
|
||||
)
|
||||
.await;
|
||||
serde_json::to_value(
|
||||
serde_json::json!({ "count": criteria.len(), "criteria": criteria }),
|
||||
)
|
||||
.map_err(ToolError::from)
|
||||
serde_json::to_value(serde_json::json!({ "count": criteria.len(), "criteria": criteria }))
|
||||
.map_err(ToolError::from)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! Read-query functions for the constellation-memory surface (component J,
|
||||
//! ADR-051 / ADR-053). Each function reads NCL files via the cache and returns
|
||||
//! typed vectors — the same underlying data regardless of which surface calls it
|
||||
//! (HTTP GET, GraphQL resolver, MCP tool). This is the G-4 equivalence contract
|
||||
//! made concrete: one data path, four display surfaces.
|
||||
//! typed vectors — the same underlying data regardless of which surface calls
|
||||
//! it (HTTP GET, GraphQL resolver, MCP tool). This is the G-4 equivalence
|
||||
//! contract made concrete: one data path, four display surfaces.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -16,9 +16,10 @@ fn str_val<'a>(v: &'a serde_json::Value, key: &str) -> &'a str {
|
|||
|
||||
/// Chronicle entries woven from ADRs and protocol migrations.
|
||||
///
|
||||
/// Sources: `.ontoref/adrs/*.ncl` (Accepted status) + `.ontoref/reflection/migrations/*.ncl`.
|
||||
/// Optional filters: `since` (ISO date prefix), `kind` ("adr" | "migration"), `domain`.
|
||||
/// Result is sorted descending by date.
|
||||
/// Sources: `.ontoref/adrs/*.ncl` (Accepted status) +
|
||||
/// `.ontoref/reflection/migrations/*.ncl`. Optional filters: `since` (ISO date
|
||||
/// prefix), `kind` ("adr" | "migration"), `domain`. Result is sorted descending
|
||||
/// by date.
|
||||
pub async fn chronicle_entries(
|
||||
root: &Path,
|
||||
cache: &Arc<NclCache>,
|
||||
|
|
@ -47,13 +48,20 @@ pub async fn chronicle_entries(
|
|||
let id = str_val(&json, "id").to_owned();
|
||||
let date = str_val(&json, "date").to_owned();
|
||||
let title_raw = str_val(&json, "title");
|
||||
let title = if title_raw.is_empty() { id.clone() } else { title_raw.to_owned() };
|
||||
let title = if title_raw.is_empty() {
|
||||
id.clone()
|
||||
} else {
|
||||
title_raw.to_owned()
|
||||
};
|
||||
entries.push(ChronicleEntry {
|
||||
id: format!("adr:{id}"),
|
||||
date,
|
||||
kind: "adr".to_owned(),
|
||||
title,
|
||||
summary: json.get("context").and_then(|v| v.as_str()).map(str::to_owned),
|
||||
summary: json
|
||||
.get("context")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned),
|
||||
domain: None,
|
||||
refs: if id.is_empty() { vec![] } else { vec![id] },
|
||||
});
|
||||
|
|
@ -121,7 +129,8 @@ pub async fn chronicle_entries(
|
|||
|
||||
/// Roadmap items derived from backlog entries.
|
||||
///
|
||||
/// Source: `.ontoref/reflection/backlog.ncl`. Optional filters: `status`, `horizon`.
|
||||
/// Source: `.ontoref/reflection/backlog.ncl`. Optional filters: `status`,
|
||||
/// `horizon`.
|
||||
pub async fn roadmap_items(
|
||||
root: &Path,
|
||||
cache: &Arc<NclCache>,
|
||||
|
|
@ -185,8 +194,9 @@ pub async fn roadmap_items(
|
|||
|
||||
/// Criteria derived from catalog validator declarations.
|
||||
///
|
||||
/// Source: `.ontoref/catalog/validators/*.ncl`. Optional filter: `kind` ("normative").
|
||||
/// All catalog-declared validators are normative (explicitly promoted per ADR-050).
|
||||
/// Source: `.ontoref/catalog/validators/*.ncl`. Optional filter: `kind`
|
||||
/// ("normative"). All catalog-declared validators are normative (explicitly
|
||||
/// promoted per ADR-050).
|
||||
pub async fn criteria_list(
|
||||
root: &Path,
|
||||
cache: &Arc<NclCache>,
|
||||
|
|
@ -232,11 +242,19 @@ pub async fn criteria_list(
|
|||
provenance: None,
|
||||
validator_ref: {
|
||||
let r = str_val(&json, "predicate_ref");
|
||||
if r.is_empty() { None } else { Some(r.to_owned()) }
|
||||
if r.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(r.to_owned())
|
||||
}
|
||||
},
|
||||
related_adr: {
|
||||
let r = str_val(&json, "criterion_ref");
|
||||
if r.is_empty() { None } else { Some(r.to_owned()) }
|
||||
if r.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(r.to_owned())
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -245,9 +263,11 @@ pub async fn criteria_list(
|
|||
criteria
|
||||
}
|
||||
|
||||
/// Panel specs resolved from dedicated panel-*.ncl files in the presentation directory.
|
||||
/// Panel specs resolved from dedicated panel-*.ncl files in the presentation
|
||||
/// directory.
|
||||
///
|
||||
/// Source: `.ontoref/presentation/panel-*.ncl`. Optional filter: `kind` (Timeline/Gantt/…).
|
||||
/// Source: `.ontoref/presentation/panel-*.ncl`. Optional filter: `kind`
|
||||
/// (Timeline/Gantt/…).
|
||||
pub async fn panels_list(
|
||||
root: &Path,
|
||||
cache: &Arc<NclCache>,
|
||||
|
|
@ -266,12 +286,8 @@ pub async fn panels_list(
|
|||
let mut panels = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("ncl") || !stem.starts_with("panel-")
|
||||
{
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("ncl") || !stem.starts_with("panel-") {
|
||||
continue;
|
||||
}
|
||||
let Ok((json, _)) = cache.export(&path, import_path).await else {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One woven point on the retrospective chronicle timeline (ADR-051, component B).
|
||||
/// One woven point on the retrospective chronicle timeline (ADR-051, component
|
||||
/// B).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
|
||||
pub struct ChronicleEntry {
|
||||
|
|
@ -21,7 +22,8 @@ pub struct ChronicleEntry {
|
|||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
/// A roadmap item composed from the backlog roadmap and positioning roadmap (component B/G).
|
||||
/// A roadmap item composed from the backlog roadmap and positioning roadmap
|
||||
/// (component B/G).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
|
||||
pub struct RoadmapItem {
|
||||
|
|
@ -46,7 +48,8 @@ pub struct Criterion {
|
|||
pub related_adr: Option<String>,
|
||||
}
|
||||
|
||||
/// A resolved panel descriptor — identifying and dispatch fields of a Panel (ADR-053).
|
||||
/// A resolved panel descriptor — identifying and dispatch fields of a Panel
|
||||
/// (ADR-053).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "graphql", derive(async_graphql::SimpleObject))]
|
||||
pub struct PanelSpec {
|
||||
|
|
|
|||
|
|
@ -20,12 +20,11 @@ use std::collections::HashMap;
|
|||
use std::sync::Mutex;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use ontoref_sync::{SyncBackend, SyncError};
|
||||
use ontoref_types::{canonical, Operation};
|
||||
use platform_nats::topology::{AckPolicy, ConsumerDef, Retention, Storage, StreamDef};
|
||||
use platform_nats::{EventStream, NatsConnectionConfig, TopologyConfig};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
/// NATS JetStream sync backend bound to one project stream and one durable
|
||||
/// consumer (per local peer). Holds its own runtime — see module docs.
|
||||
|
|
@ -239,13 +238,14 @@ fn anyhow_io(context: &str, e: &anyhow::Error) -> SyncError {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_types::{
|
||||
operation::{OpBody, OpPayload},
|
||||
AttrId, EntityId, Hlc, PublicKey, Value,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// End-to-end round-trip against a real broker. Env-gated on
|
||||
/// `ONTOREF_NATS_URL` (e.g. `nats://0.0.0.0:4222`); skips when unset so
|
||||
/// CI without a broker stays green. Run with the broker:
|
||||
|
|
|
|||
|
|
@ -8,22 +8,20 @@
|
|||
//! produce equivalent witnesses for the same `(op_id, inputs)`; this
|
||||
//! function is the contract.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_ontology::types::StateConfig;
|
||||
use ontoref_ops::{dispatch_op, OpContext, OpError, PipelineResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "substrate")]
|
||||
use std::collections::HashMap;
|
||||
#[cfg(feature = "substrate")]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(feature = "substrate")]
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_ontology::types::StateConfig;
|
||||
#[cfg(feature = "substrate")]
|
||||
use ontoref_oplog::OpLog;
|
||||
use ontoref_ops::{dispatch_op, OpContext, OpError, PipelineResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// JSON envelope every surface decodes into. `inputs` is the op-specific
|
||||
/// payload; the remaining fields configure the [`OpContext`].
|
||||
|
|
@ -102,10 +100,11 @@ fn parse_seed_hex(hex: &str) -> Result<[u8; 32], DispatchSurfaceError> {
|
|||
}
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
|
||||
.map_err(|_| DispatchSurfaceError::InvalidSeed {
|
||||
out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| {
|
||||
DispatchSurfaceError::InvalidSeed {
|
||||
reason: format!("non-hex byte at position {}", i * 2),
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -186,11 +185,12 @@ fn oplog_for(project_root: &Path) -> Result<Option<Arc<OpLog>>, DispatchSurfaceE
|
|||
if let Some(existing) = cache.get(&oplog_path) {
|
||||
return Ok(Some(Arc::clone(existing)));
|
||||
}
|
||||
let opened = Arc::new(OpLog::open(&oplog_path).map_err(|e| {
|
||||
DispatchSurfaceError::OplogUnavailable {
|
||||
reason: format!("{}: {e}", oplog_path.display()),
|
||||
}
|
||||
})?);
|
||||
let opened =
|
||||
Arc::new(
|
||||
OpLog::open(&oplog_path).map_err(|e| DispatchSurfaceError::OplogUnavailable {
|
||||
reason: format!("{}: {e}", oplog_path.display()),
|
||||
})?,
|
||||
);
|
||||
cache.insert(oplog_path, Arc::clone(&opened));
|
||||
Ok(Some(opened))
|
||||
}
|
||||
|
|
@ -352,7 +352,11 @@ mod substrate_tests {
|
|||
"no oplog file → tier-0 → in-memory, no persistence"
|
||||
);
|
||||
assert!(
|
||||
!root.join(".ontoref").join("substrate").join("oplog.redb").exists(),
|
||||
!root
|
||||
.join(".ontoref")
|
||||
.join("substrate")
|
||||
.join("oplog.redb")
|
||||
.exists(),
|
||||
"in-memory dispatch must not create an oplog"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
//! - `GET /api/positioning` — full layer
|
||||
//! - `GET /api/positioning/audience/:id` — single audience
|
||||
//! - `GET /api/positioning/value-prop/:id` — single value-prop
|
||||
//! - `POST /api/positioning/audit` — drift report (evidence_adrs → ADR status)
|
||||
//! - `POST /api/positioning/audit` — drift report (evidence_adrs → ADR
|
||||
//! status)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
@ -92,9 +93,7 @@ pub async fn positioning_get(
|
|||
let realized_viability_paths = p
|
||||
.viability_paths()
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.status != PositioningStatus::Draft && v.realized_outcome.is_some()
|
||||
})
|
||||
.filter(|v| v.status != PositioningStatus::Draft && v.realized_outcome.is_some())
|
||||
.count();
|
||||
let counts = PositioningCounts {
|
||||
audiences: p.audiences().len(),
|
||||
|
|
@ -194,7 +193,8 @@ pub enum AdrIssue {
|
|||
/// - `Missing` — cited ADR file does not exist
|
||||
/// - `Superseded` — ADR status is `'Superseded`
|
||||
/// - `Deprecated` — ADR status is `'Deprecated`
|
||||
/// - `Proposed` — ADR is still `'Proposed` (claim cites uncommitted decision)
|
||||
/// - `Proposed` — ADR is still `'Proposed` (claim cites uncommitted
|
||||
/// decision)
|
||||
///
|
||||
/// Only Accepted ADRs are clean backing for a Live value-prop. The audit
|
||||
/// reports drift across ALL value-props regardless of status — `Draft` ones
|
||||
|
|
@ -218,10 +218,7 @@ pub async fn positioning_audit(
|
|||
///
|
||||
/// Extracted from [`positioning_audit`] so the MCP surface and any other
|
||||
/// caller can reuse it without going through axum extractors.
|
||||
pub(crate) fn audit_positioning(
|
||||
p: &Positioning,
|
||||
adrs_dir: &Path,
|
||||
) -> AuditReport {
|
||||
pub(crate) fn audit_positioning(p: &Positioning, adrs_dir: &Path) -> AuditReport {
|
||||
let mut drift: Vec<DriftEntry> = Vec::new();
|
||||
for vp in p.value_props() {
|
||||
for adr_id in &vp.evidence_adrs {
|
||||
|
|
@ -317,10 +314,7 @@ mod tests {
|
|||
#[test]
|
||||
fn classify_proposed_returns_proposed() {
|
||||
let txt = "status = 'Proposed,";
|
||||
assert!(matches!(
|
||||
classify_adr_status(txt),
|
||||
Some(AdrIssue::Proposed)
|
||||
));
|
||||
assert!(matches!(classify_adr_status(txt), Some(AdrIssue::Proposed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -532,16 +532,16 @@ impl ProjectRegistry {
|
|||
/// In addition to the project's declared paths, two sets of extras are always
|
||||
/// appended:
|
||||
///
|
||||
/// 1. **Project-local schema dirs** — derived from [`LayoutConfig::detect`]
|
||||
/// via [`project_schema_dirs`]. Covers `.ontoref/ontology/`,
|
||||
/// 1. **Project-local schema dirs** — derived from [`LayoutConfig::detect`] via
|
||||
/// [`project_schema_dirs`]. Covers `.ontoref/ontology/`,
|
||||
/// `.ontoref/ontology/schemas/`, `.ontoref/adrs/`, etc. so imports like
|
||||
/// `import "manifest"` or `import "schemas/connections"` resolve from
|
||||
/// anywhere inside the project. Both canonical and legacy layouts are
|
||||
/// included when present on disk.
|
||||
///
|
||||
/// 2. **User-global schemas dir** — `~/.config/ontoref/schemas/` so
|
||||
/// `import "ontoref-project.ncl"` (the canonical project schema)
|
||||
/// resolves even when the project does not declare it.
|
||||
/// 2. **User-global schemas dir** — `~/.config/ontoref/schemas/` so `import
|
||||
/// "ontoref-project.ncl"` (the canonical project schema) resolves even when
|
||||
/// the project does not declare it.
|
||||
fn user_global_schemas_path() -> Option<String> {
|
||||
let home = std::env::var_os("HOME")?;
|
||||
let p = Path::new(&home)
|
||||
|
|
@ -646,12 +646,13 @@ pub struct ContextSpec {
|
|||
/// - the manifest has no `config_surface` field
|
||||
/// - nickel export or deserialisation fails (logged at warn level)
|
||||
pub fn load_config_surface(root: &Path, import_path: Option<&str>) -> Option<ConfigSurface> {
|
||||
// This used to hardcode the pre-0023 manifest location with no fallback, so on any
|
||||
// project migrated to the consolidated layout the file was never found: the function
|
||||
// returned None and the config surface silently vanished. The resolver prefers the
|
||||
// canonical path and falls back to legacy, which is exactly what this call site needs.
|
||||
// (Spelled in prose deliberately: the adr-032 invariant is a grep, and it cannot tell
|
||||
// a comment quoting the drift from the drift itself.)
|
||||
// This used to hardcode the pre-0023 manifest location with no fallback, so on
|
||||
// any project migrated to the consolidated layout the file was never found:
|
||||
// the function returned None and the config surface silently vanished. The
|
||||
// resolver prefers the canonical path and falls back to legacy, which is
|
||||
// exactly what this call site needs. (Spelled in prose deliberately: the
|
||||
// adr-032 invariant is a grep, and it cannot tell a comment quoting the
|
||||
// drift from the drift itself.)
|
||||
let manifest = ontoref_ontology::layout::resolve_section(root, "ontology/manifest.ncl");
|
||||
if !manifest.exists() {
|
||||
return None;
|
||||
|
|
@ -930,8 +931,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn absolute_path_returned_as_is() {
|
||||
let result =
|
||||
resolve_import_path_inner(&["/abs/path".to_string()], Path::new("/root"), &[]);
|
||||
let result = resolve_import_path_inner(&["/abs/path".to_string()], Path::new("/root"), &[]);
|
||||
assert_eq!(result, Some("/abs/path".to_string()));
|
||||
}
|
||||
|
||||
|
|
@ -989,10 +989,11 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Materialise a PRE-0023 section dir. Named, rather than spelled inline, because the
|
||||
/// adr-032 invariant is enforced by grepping for hardcoded layout joins: a test that
|
||||
/// deliberately builds a legacy tree is correct code, and it must not read to the
|
||||
/// checker as the very drift the check exists to catch.
|
||||
/// Materialise a PRE-0023 section dir. Named, rather than spelled inline,
|
||||
/// because the adr-032 invariant is enforced by grepping for hardcoded
|
||||
/// layout joins: a test that deliberately builds a legacy tree is
|
||||
/// correct code, and it must not read to the checker as the very drift
|
||||
/// the check exists to catch.
|
||||
fn legacy_section(root: &Path, relative: &str) -> PathBuf {
|
||||
root.join(relative)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,9 +82,10 @@ pub fn rotate_actor_key(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ontoref_types::{operation::OpPayload, valid_keys_for_actor, PublicKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rotation_records_succession_and_preserves_old_key_validity() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
|
@ -117,11 +118,17 @@ mod tests {
|
|||
let ops = oplog.all_ops_in_hlc_order().unwrap();
|
||||
assert_eq!(ops.len(), 1);
|
||||
ops[0].verify_signature().expect("succession verifies");
|
||||
assert!(matches!(ops[0].body.payload, OpPayload::KeySuccession { .. }));
|
||||
assert!(matches!(
|
||||
ops[0].body.payload,
|
||||
OpPayload::KeySuccession { .. }
|
||||
));
|
||||
assert_eq!(ops[0].body.actor, old_pub, "signed by the retiring key");
|
||||
|
||||
let valid = valid_keys_for_actor(&ops, "agent", new_pub);
|
||||
assert!(valid.contains(&old_pub), "witnesses under the old key still verify");
|
||||
assert!(
|
||||
valid.contains(&old_pub),
|
||||
"witnesses under the old key still verify"
|
||||
);
|
||||
assert!(valid.contains(&new_pub));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,12 @@
|
|||
|
||||
use std::time::Duration;
|
||||
|
||||
pub use auth::session::SessionView;
|
||||
use auth::session::{
|
||||
self as auth_session, RevokeAuthorisation, RevokeResult as AuthRevokeResult, Session,
|
||||
SessionStore as AuthSessionStore,
|
||||
};
|
||||
|
||||
pub use auth::session::SessionView;
|
||||
|
||||
use crate::registry::Role;
|
||||
|
||||
/// Cookie name used by the daemon UI for browser sessions.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ use axum::extract::{Path, State};
|
|||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::api::AppState;
|
||||
|
||||
use super::common::UiError;
|
||||
use crate::api::AppState;
|
||||
|
||||
/// Serve a file from `{project_root}/assets/` by slug + relative path.
|
||||
/// Validates that the resolved path stays under the assets directory.
|
||||
|
|
@ -88,10 +87,7 @@ pub(crate) fn asset_content_type(path: &std::path::Path) -> &'static str {
|
|||
}
|
||||
|
||||
/// Generic file server for an arbitrary base directory.
|
||||
pub(crate) async fn serve_dir_from(
|
||||
base: &std::path::Path,
|
||||
rel: &str,
|
||||
) -> Result<Response, UiError> {
|
||||
pub(crate) async fn serve_dir_from(base: &std::path::Path, rel: &str) -> Result<Response, UiError> {
|
||||
let Ok(canonical_base) = base.canonicalize() else {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
};
|
||||
|
|
|
|||
|
|
@ -99,7 +99,8 @@ pub(crate) fn backlog_stats(items: &[serde_json::Value]) -> BacklogStats {
|
|||
|
||||
pub async fn backlog_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
|
||||
let tera = tera_ref(&state)?;
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/backlog.ncl");
|
||||
let items = if backlog_path.exists() {
|
||||
load_backlog_items(
|
||||
&state.cache,
|
||||
|
|
@ -136,7 +137,8 @@ pub async fn backlog_page_mp(
|
|||
) -> Result<Html<String>, UiError> {
|
||||
let tera = tera_ref(&state)?;
|
||||
let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let items = if backlog_path.exists() {
|
||||
load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await
|
||||
} else {
|
||||
|
|
@ -170,13 +172,14 @@ pub async fn backlog_update_status(
|
|||
State(state): State<AppState>,
|
||||
Form(form): Form<BacklogStatusForm>,
|
||||
) -> Result<Response, UiError> {
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/backlog.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
|
||||
super::super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso())
|
||||
.map_err(|e| UiError::NclExport {
|
||||
path: "backlog.ncl".into(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
path: "backlog.ncl".into(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
state
|
||||
.cache
|
||||
.invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path));
|
||||
|
|
@ -196,13 +199,14 @@ pub async fn backlog_update_status_mp(
|
|||
));
|
||||
}
|
||||
let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
|
||||
super::super::backlog_ncl::update_status(&backlog_path, &form.id, &form.status, &today_iso())
|
||||
.map_err(|e| UiError::NclExport {
|
||||
path: "backlog.ncl".into(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
path: "backlog.ncl".into(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
proj.cache
|
||||
.invalidate_file(&backlog_path.canonicalize().unwrap_or(backlog_path.clone()));
|
||||
|
||||
|
|
@ -224,7 +228,8 @@ pub async fn backlog_add(
|
|||
State(state): State<AppState>,
|
||||
Form(form): Form<BacklogAddForm>,
|
||||
) -> Result<Response, UiError> {
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/backlog.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
|
||||
super::super::backlog_ncl::add_item(
|
||||
&backlog_path,
|
||||
|
|
@ -257,7 +262,8 @@ pub async fn backlog_add_mp(
|
|||
));
|
||||
}
|
||||
let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
|
||||
super::super::backlog_ncl::add_item(
|
||||
&backlog_path,
|
||||
|
|
@ -301,7 +307,8 @@ pub async fn backlog_edit_mp(
|
|||
));
|
||||
}
|
||||
let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
|
||||
super::super::backlog_ncl::update_item(
|
||||
&backlog_path,
|
||||
|
|
@ -352,7 +359,8 @@ pub async fn backlog_delete_mp(
|
|||
));
|
||||
}
|
||||
let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
|
||||
super::super::backlog_ncl::delete_item(&backlog_path, &form.id).map_err(|e| {
|
||||
UiError::NclExport {
|
||||
|
|
@ -386,7 +394,8 @@ pub async fn backlog_propose_status_mp(
|
|||
) -> Result<Response, UiError> {
|
||||
let proj = state.registry.get(&slug).ok_or(UiError::NotConfigured)?;
|
||||
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let items = load_backlog_items(&proj.cache, &backlog_path, proj.import_path.as_deref()).await;
|
||||
let item_title = items
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use tera::Context;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
|
|
@ -479,7 +479,8 @@ pub(crate) async fn resolve_domain_ctx(
|
|||
.unwrap_or("")
|
||||
.to_string();
|
||||
for proj_ctx in registry.all() {
|
||||
let mpath = ontoref_ontology::layout::resolve_section(&proj_ctx.root, "ontology/manifest.ncl");
|
||||
let mpath =
|
||||
ontoref_ontology::layout::resolve_section(&proj_ctx.root, "ontology/manifest.ncl");
|
||||
if !mpath.exists() {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ use super::common::{
|
|||
ncl_export, now_hms, readme_description, render, tera_ref, UiError,
|
||||
};
|
||||
use super::graph_helpers::{
|
||||
api_catalog_graph_nodes, manifest_graph_nodes, read_project_api_catalogs,
|
||||
state_graph_nodes, workspace_crate_nodes,
|
||||
api_catalog_graph_nodes, manifest_graph_nodes, read_project_api_catalogs, state_graph_nodes,
|
||||
workspace_crate_nodes,
|
||||
};
|
||||
use super::picker::{
|
||||
count_ncl_files, detect_generated, detect_showcase, git_remotes, load_pending_migrations,
|
||||
};
|
||||
use super::picker::{count_ncl_files, detect_generated, detect_showcase, load_pending_migrations, git_remotes};
|
||||
use crate::api::AppState;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
|
|
@ -148,7 +150,8 @@ pub async fn dashboard_mp(
|
|||
};
|
||||
|
||||
let base_url = format!("/ui/{slug}");
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&ctx_ref.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx_ref.root, "reflection/backlog.ncl");
|
||||
let backlog_items = if backlog_path.exists() {
|
||||
load_backlog_items(
|
||||
&ctx_ref.cache,
|
||||
|
|
@ -160,8 +163,14 @@ pub async fn dashboard_mp(
|
|||
vec![]
|
||||
};
|
||||
let backlog = backlog_stats(&backlog_items);
|
||||
let adr_count = count_ncl_files(&ontoref_ontology::layout::resolve_section(&ctx_ref.root, "adrs"));
|
||||
let mode_count = count_ncl_files(&ontoref_ontology::layout::resolve_section(&ctx_ref.root, "reflection/modes"));
|
||||
let adr_count = count_ncl_files(&ontoref_ontology::layout::resolve_section(
|
||||
&ctx_ref.root,
|
||||
"adrs",
|
||||
));
|
||||
let mode_count = count_ncl_files(&ontoref_ontology::layout::resolve_section(
|
||||
&ctx_ref.root,
|
||||
"reflection/modes",
|
||||
));
|
||||
|
||||
// ── Project identity (same data as project_picker card) ──────────────────
|
||||
let config_json = load_config_json(
|
||||
|
|
@ -184,7 +193,8 @@ pub async fn dashboard_mp(
|
|||
} else {
|
||||
String::new()
|
||||
};
|
||||
let manifest_path = ontoref_ontology::layout::resolve_section(&ctx_ref.root, "ontology/manifest.ncl");
|
||||
let manifest_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx_ref.root, "ontology/manifest.ncl");
|
||||
let (layers, op_modes, default_mode, repo_kind) = if manifest_path.exists() {
|
||||
match ctx_ref
|
||||
.cache
|
||||
|
|
@ -388,7 +398,8 @@ pub async fn graph_mp(
|
|||
let (ws_nodes, ws_edges) = workspace_crate_nodes(&ctx_ref.root);
|
||||
|
||||
// Merge manifest config sections + requirements.
|
||||
let manifest_path = ontoref_ontology::layout::resolve_section(&ctx_ref.root, "ontology/manifest.ncl");
|
||||
let manifest_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx_ref.root, "ontology/manifest.ncl");
|
||||
let (manifest_nodes, manifest_edges) = if manifest_path.exists() {
|
||||
match ctx_ref
|
||||
.cache
|
||||
|
|
|
|||
|
|
@ -168,8 +168,9 @@ async fn load_registry_entry(
|
|||
.export(&project_ncl, state.nickel_import_path.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok((json, _)) => serde_json::from_value(json)
|
||||
.unwrap_or_else(|_| make_registry_entry_from_root(root)),
|
||||
Ok((json, _)) => {
|
||||
serde_json::from_value(json).unwrap_or_else(|_| make_registry_entry_from_root(root))
|
||||
}
|
||||
Err(_) => make_registry_entry_from_root(root),
|
||||
}
|
||||
} else {
|
||||
|
|
@ -177,7 +178,8 @@ async fn load_registry_entry(
|
|||
}
|
||||
}
|
||||
|
||||
/// Guarded variant: requires `AdminGuard` (any admin session or no auth configured).
|
||||
/// Guarded variant: requires `AdminGuard` (any admin session or no auth
|
||||
/// configured).
|
||||
pub async fn manage_page_guarded(
|
||||
State(state): State<AppState>,
|
||||
_guard: super::super::auth::AdminGuard,
|
||||
|
|
|
|||
|
|
@ -12,58 +12,42 @@ pub mod session;
|
|||
|
||||
// ── Re-exports — public surface identical to the old handlers.rs ─────────────
|
||||
|
||||
pub(crate) use common::{
|
||||
insert_brand_ctx, insert_mcp_ctx, readme_description, render, UiError,
|
||||
// Assets + public file serving
|
||||
pub use assets::{serve_asset_mp, serve_asset_single, serve_public_mp, serve_public_single};
|
||||
// Backlog
|
||||
pub use backlog::{
|
||||
backlog_add, backlog_add_mp, backlog_delete_mp, backlog_edit_mp, backlog_page, backlog_page_mp,
|
||||
backlog_propose_status_mp, backlog_update_status, backlog_update_status_mp, BacklogAddForm,
|
||||
BacklogDeleteForm, BacklogEditForm, BacklogProposeForm, BacklogStatusForm,
|
||||
};
|
||||
|
||||
pub(crate) use common::{insert_brand_ctx, insert_mcp_ctx, readme_description, render, UiError};
|
||||
// Compose
|
||||
pub use compose::{compose_form_schema_mp, compose_page_mp, compose_send_mp, ComposeSendBody};
|
||||
// Dashboard + graph
|
||||
pub use dashboard::{
|
||||
api_catalog_page_mp, dashboard, dashboard_mp, dashboard_stats_mp, graph, graph_mp,
|
||||
};
|
||||
|
||||
// Sessions + notifications
|
||||
pub use session::{
|
||||
emit_notification_mp, notification_action_mp, notifications_mp, notifications_page,
|
||||
sessions, sessions_mp, EmitNotifForm, NotifActionForm,
|
||||
};
|
||||
|
||||
// Project picker
|
||||
pub(crate) use picker::{pending_migrations_fragment_mp, project_picker};
|
||||
|
||||
// Manage
|
||||
pub use manage::{
|
||||
manage_add, manage_add_guarded, manage_logout, manage_page, manage_page_guarded,
|
||||
manage_remove, manage_remove_guarded, service_toggle_ui, AddProjectForm, RemoveProjectForm,
|
||||
manage_add, manage_add_guarded, manage_logout, manage_page, manage_page_guarded, manage_remove,
|
||||
manage_remove_guarded, service_toggle_ui, AddProjectForm, RemoveProjectForm,
|
||||
};
|
||||
|
||||
// Backlog
|
||||
pub use backlog::{
|
||||
backlog_add, backlog_add_mp, backlog_delete_mp, backlog_edit_mp, backlog_page,
|
||||
backlog_page_mp, backlog_propose_status_mp, backlog_update_status, backlog_update_status_mp,
|
||||
BacklogAddForm, BacklogDeleteForm, BacklogEditForm, BacklogProposeForm, BacklogStatusForm,
|
||||
// Pages — modes, actions, qa, config, adrs, domain, tier, timeline, roadmap, panel
|
||||
pub use pages::{
|
||||
actions_page, actions_page_mp, actions_run, actions_run_mp, adrs_page_mp, career_page_mp,
|
||||
config_page_mp, modes, modes_mp, panel_page_mp, personal_page_mp, provisioning_page_mp, qa_add,
|
||||
qa_delete, qa_page, qa_page_mp, qa_update, roadmap_page_mp, tier_page_mp, timeline_page_mp,
|
||||
views_page, views_page_mp, ActionRunForm, QaAddRequest, QaDeleteRequest, QaUpdateRequest,
|
||||
};
|
||||
|
||||
// Project picker
|
||||
pub(crate) use picker::{pending_migrations_fragment_mp, project_picker};
|
||||
// Search + bookmarks
|
||||
pub use search::{
|
||||
search_bookmark_add, search_bookmark_delete, search_page, search_page_mp, BookmarkAddRequest,
|
||||
BookmarkDeleteRequest,
|
||||
};
|
||||
|
||||
// Pages — modes, actions, qa, config, adrs, domain, tier, timeline, roadmap, panel
|
||||
pub use pages::{
|
||||
actions_page, actions_page_mp, actions_run, actions_run_mp, adrs_page_mp, career_page_mp,
|
||||
config_page_mp, modes, modes_mp, panel_page_mp, personal_page_mp, provisioning_page_mp,
|
||||
qa_add, qa_delete, qa_page, qa_page_mp, qa_update, roadmap_page_mp, tier_page_mp,
|
||||
timeline_page_mp, views_page, views_page_mp, ActionRunForm, QaAddRequest, QaDeleteRequest,
|
||||
QaUpdateRequest,
|
||||
};
|
||||
|
||||
// Compose
|
||||
pub use compose::{
|
||||
compose_form_schema_mp, compose_page_mp, compose_send_mp, ComposeSendBody,
|
||||
};
|
||||
|
||||
// Assets + public file serving
|
||||
pub use assets::{
|
||||
serve_asset_mp, serve_asset_single, serve_public_mp, serve_public_single,
|
||||
// Sessions + notifications
|
||||
pub use session::{
|
||||
emit_notification_mp, notification_action_mp, notifications_mp, notifications_page, sessions,
|
||||
sessions_mp, EmitNotifForm, NotifActionForm,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ use tera::Context;
|
|||
use tracing::warn;
|
||||
|
||||
use super::super::auth::AuthUser;
|
||||
use super::common::{
|
||||
auth_role_str, insert_brand_ctx, now_iso, render, tera_ref, UiError,
|
||||
};
|
||||
use super::common::{auth_role_str, insert_brand_ctx, now_iso, render, tera_ref, UiError};
|
||||
use super::picker::{detect_generated, detect_showcase};
|
||||
use crate::api::AppState;
|
||||
use crate::memory_types::ChronicleEntry;
|
||||
|
|
@ -21,7 +19,8 @@ use crate::memory_types::ChronicleEntry;
|
|||
pub async fn modes(State(state): State<AppState>) -> Result<Html<String>, UiError> {
|
||||
let tera = tera_ref(&state)?;
|
||||
|
||||
let modes_dir = ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/modes");
|
||||
let modes_dir =
|
||||
ontoref_ontology::layout::resolve_section(&state.project_root, "reflection/modes");
|
||||
let mut mode_entries: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&modes_dir) {
|
||||
|
|
@ -362,7 +361,8 @@ fn resolve_qa_ctx(
|
|||
}
|
||||
|
||||
/// Substrate views panel (ADR-046). Read-only — the page fetches /api/views and
|
||||
/// /api/views/{discipline}/mount client-side, so the handler only renders the shell.
|
||||
/// /api/views/{discipline}/mount client-side, so the handler only renders the
|
||||
/// shell.
|
||||
pub async fn views_page(State(state): State<AppState>) -> Result<Html<String>, UiError> {
|
||||
let tera = tera_ref(&state)?;
|
||||
let mut ctx = Context::new();
|
||||
|
|
@ -506,7 +506,15 @@ pub async fn qa_add(
|
|||
let related = body.related.as_deref().unwrap_or(&[]);
|
||||
let now = now_iso();
|
||||
|
||||
match super::super::qa_ncl::add_entry(&qa_path, &body.question, answer, actor, &now, tags, related) {
|
||||
match super::super::qa_ncl::add_entry(
|
||||
&qa_path,
|
||||
&body.question,
|
||||
answer,
|
||||
actor,
|
||||
&now,
|
||||
tags,
|
||||
related,
|
||||
) {
|
||||
Ok(id) => {
|
||||
cache.invalidate_file(&qa_path);
|
||||
(
|
||||
|
|
@ -767,7 +775,8 @@ pub async fn adrs_page_mp(
|
|||
render(tera, "pages/adrs.html", &ctx).await
|
||||
}
|
||||
|
||||
// ── Domain extension pages ────────────────────────────────────────────────────
|
||||
// ── Domain extension pages
|
||||
// ────────────────────────────────────────────────────
|
||||
|
||||
/// Personal ontology page — contents pipeline + opportunities.
|
||||
pub async fn personal_page_mp(
|
||||
|
|
@ -869,9 +878,8 @@ pub async fn career_page_mp(
|
|||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let talks = serde_json::to_string(v.get("talks").unwrap_or(&empty))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let positioning =
|
||||
serde_json::to_string(v.get("positioning").unwrap_or(&empty))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let positioning = serde_json::to_string(v.get("positioning").unwrap_or(&empty))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let publications =
|
||||
serde_json::to_string(v.get("publications").unwrap_or(&empty))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
|
|
@ -934,7 +942,8 @@ pub async fn provisioning_page_mp(
|
|||
let cache = ctx_ref.cache.clone();
|
||||
let root = ctx_ref.root.clone();
|
||||
|
||||
let connections_path = ontoref_ontology::layout::resolve_section(&root, "ontology/connections.ncl");
|
||||
let connections_path =
|
||||
ontoref_ontology::layout::resolve_section(&root, "ontology/connections.ncl");
|
||||
let connections_json = if connections_path.exists() {
|
||||
match cache
|
||||
.export(&connections_path, import_path.as_deref())
|
||||
|
|
@ -987,7 +996,8 @@ pub async fn provisioning_page_mp(
|
|||
render(tera, "pages/provisioning.html", &ctx).await
|
||||
}
|
||||
|
||||
// ── Tier page ─────────────────────────────────────────────────────────────────
|
||||
// ── Tier page
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn tier_page_mp(
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -1083,7 +1093,8 @@ pub async fn tier_page_mp(
|
|||
render(tera, "pages/tier.html", &ctx).await
|
||||
}
|
||||
|
||||
// ── Chronicle timeline ────────────────────────────────────────────────────────
|
||||
// ── Chronicle timeline
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
/// `GET /{slug}/timeline` — render the chronicle timeline panel.
|
||||
/// Reads all `entries.jsonl` files under `{root}/.coder/` and serves them as
|
||||
|
|
@ -1099,8 +1110,7 @@ pub async fn timeline_page_mp(
|
|||
|
||||
let coder_dir = ctx_ref.root.join(".coder");
|
||||
let entries = collect_chronicle_entries(&coder_dir);
|
||||
let timeline_json =
|
||||
serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string());
|
||||
let timeline_json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert("slug", &slug);
|
||||
|
|
@ -1142,31 +1152,60 @@ fn parse_chronicle_line(line: &str, idx: usize) -> Option<ChronicleEntry> {
|
|||
return Some(e);
|
||||
}
|
||||
let v = serde_json::from_str::<serde_json::Value>(line).ok()?;
|
||||
let id = v.get("id")
|
||||
let id = v
|
||||
.get("id")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| {
|
||||
let date = v.get("date").and_then(|x| x.as_str()).unwrap_or("0000");
|
||||
format!("{date}-{idx}")
|
||||
});
|
||||
let title = v.get("title").and_then(|x| x.as_str()).unwrap_or("(untitled)").to_string();
|
||||
let date = v.get("date").and_then(|x| x.as_str()).unwrap_or("1970-01-01").to_string();
|
||||
let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("info").to_string();
|
||||
let refs: Vec<String> = v.get("relates_to")
|
||||
let title = v
|
||||
.get("title")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("(untitled)")
|
||||
.to_string();
|
||||
let date = v
|
||||
.get("date")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("1970-01-01")
|
||||
.to_string();
|
||||
let kind = v
|
||||
.get("kind")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("info")
|
||||
.to_string();
|
||||
let refs: Vec<String> = v
|
||||
.get("relates_to")
|
||||
.or_else(|| v.get("refs"))
|
||||
.and_then(|x| x.as_array())
|
||||
.map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|r| r.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let summary = v.get("content")
|
||||
let summary = v
|
||||
.get("content")
|
||||
.or_else(|| v.get("summary"))
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.chars().take(200).collect());
|
||||
let domain = v.get("domain").and_then(|x| x.as_str()).map(String::from);
|
||||
Some(ChronicleEntry { id, title, date, kind, summary, domain, refs })
|
||||
Some(ChronicleEntry {
|
||||
id,
|
||||
title,
|
||||
date,
|
||||
kind,
|
||||
summary,
|
||||
domain,
|
||||
refs,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_entries_recursive(dir: &std::path::Path, out: &mut Vec<ChronicleEntry>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
|
|
@ -1174,14 +1213,18 @@ fn collect_entries_recursive(dir: &std::path::Path, out: &mut Vec<ChronicleEntry
|
|||
} else if path.file_name().and_then(|n| n.to_str()) == Some("entries.jsonl") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
out.extend(
|
||||
content.lines().enumerate().filter_map(|(idx, l)| parse_chronicle_line(l.trim(), idx))
|
||||
content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, l)| parse_chronicle_line(l.trim(), idx)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Positioning roadmap ───────────────────────────────────────────────────────
|
||||
// ── Positioning roadmap
|
||||
// ───────────────────────────────────────────────────────
|
||||
|
||||
/// `GET /{slug}/roadmap` — render the positioning roadmap panel.
|
||||
/// Loads positioning data via `Positioning::load` and serves mechanisms +
|
||||
|
|
@ -1196,52 +1239,58 @@ pub async fn roadmap_page_mp(
|
|||
let base_url = format!("/ui/{slug}");
|
||||
|
||||
let pos_dir = ctx_ref.root.join(".ontoref").join("positioning");
|
||||
let (mechanisms, milestones, orphan_milestones) =
|
||||
if pos_dir.exists() {
|
||||
match ontoref_ontology::ontology::Positioning::load(&pos_dir) {
|
||||
Ok(pos) => {
|
||||
let mechs: Vec<serde_json::Value> = pos
|
||||
let (mechanisms, milestones, orphan_milestones) = if pos_dir.exists() {
|
||||
match ontoref_ontology::ontology::Positioning::load(&pos_dir) {
|
||||
Ok(pos) => {
|
||||
let mechs: Vec<serde_json::Value> = pos
|
||||
.mechanisms()
|
||||
.iter()
|
||||
.map(|m| serde_json::json!({ "id": m.id, "label": m.name, "status": format!("{:?}", m.status) }))
|
||||
.collect();
|
||||
// milestones come from the chronicle entries in .coder/
|
||||
let coder_dir = ctx_ref.root.join(".coder");
|
||||
let all_entries = collect_chronicle_entries(&coder_dir);
|
||||
let ms: Vec<serde_json::Value> = all_entries
|
||||
.into_iter()
|
||||
.filter(|e| e.kind == "milestone")
|
||||
.map(|e| serde_json::json!({
|
||||
// milestones come from the chronicle entries in .coder/
|
||||
let coder_dir = ctx_ref.root.join(".coder");
|
||||
let all_entries = collect_chronicle_entries(&coder_dir);
|
||||
let ms: Vec<serde_json::Value> = all_entries
|
||||
.into_iter()
|
||||
.filter(|e| e.kind == "milestone")
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": e.id, "title": e.title, "date": e.date,
|
||||
"refs": e.refs, "mechanism": e.refs.first().cloned(),
|
||||
}))
|
||||
.collect();
|
||||
let diff_adrs: std::collections::HashSet<String> = pos
|
||||
.differentiators()
|
||||
.iter()
|
||||
.flat_map(|d| d.evidence_adrs.clone())
|
||||
.collect();
|
||||
let orphans: Vec<serde_json::Value> = ms
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let refs = m.get("refs")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
!refs.iter().any(|r| diff_adrs.contains(r))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
(mechs, ms, orphans)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(slug = %slug, error = %e, "roadmap: positioning load failed");
|
||||
(vec![], vec![], vec![])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let diff_adrs: std::collections::HashSet<String> = pos
|
||||
.differentiators()
|
||||
.iter()
|
||||
.flat_map(|d| d.evidence_adrs.clone())
|
||||
.collect();
|
||||
let orphans: Vec<serde_json::Value> = ms
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let refs = m
|
||||
.get("refs")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
!refs.iter().any(|r| diff_adrs.contains(r))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
(mechs, ms, orphans)
|
||||
}
|
||||
} else {
|
||||
(vec![], vec![], vec![])
|
||||
};
|
||||
Err(e) => {
|
||||
warn!(slug = %slug, error = %e, "roadmap: positioning load failed");
|
||||
(vec![], vec![], vec![])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(vec![], vec![], vec![])
|
||||
};
|
||||
|
||||
let roadmap_json = serde_json::to_string(&serde_json::json!({
|
||||
"mechanisms": mechanisms,
|
||||
|
|
@ -1271,7 +1320,8 @@ pub async fn roadmap_page_mp(
|
|||
render(tera, "pages/roadmap.html", &ctx).await
|
||||
}
|
||||
|
||||
// ── Generic panel ─────────────────────────────────────────────────────────────
|
||||
// ── Generic panel
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// `GET /{slug}/panel/{id}` — render a presentation-spec panel by id.
|
||||
/// Loads the panel NCL from `.ontoref/presentation/panel-{id}.ncl` and
|
||||
|
|
@ -1286,7 +1336,8 @@ pub async fn panel_page_mp(
|
|||
let base_url = format!("/ui/{slug}");
|
||||
let _ = auth;
|
||||
|
||||
let panel_path = ctx_ref.root
|
||||
let panel_path = ctx_ref
|
||||
.root
|
||||
.join(".ontoref")
|
||||
.join("presentation")
|
||||
.join(format!("panel-{panel_id}.ncl"));
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ use tera::Context;
|
|||
|
||||
use super::super::auth::AuthUser;
|
||||
use super::common::{
|
||||
epoch_secs, extract_card_from_config, extract_registry_ctx, extract_vault_ctx,
|
||||
insert_mcp_ctx, load_config_json, readme_description, render, tera_ref, UiError,
|
||||
epoch_secs, extract_card_from_config, extract_registry_ctx, extract_vault_ctx, insert_mcp_ctx,
|
||||
load_config_json, readme_description, render, tera_ref, UiError,
|
||||
};
|
||||
use crate::api::AppState;
|
||||
|
||||
|
|
@ -20,7 +20,8 @@ pub(crate) fn count_ncl_files(dir: &std::path::Path) -> usize {
|
|||
}
|
||||
|
||||
/// Read git remote names+URLs from `{root}/.git/config` by parsing the ini file
|
||||
/// directly. Returns `[{ "name": "origin", "url": "git@github.com:..." }, ...]`.
|
||||
/// directly. Returns `[{ "name": "origin", "url": "git@github.com:..." },
|
||||
/// ...]`.
|
||||
pub(crate) fn git_remotes(root: &std::path::Path) -> Vec<serde_json::Value> {
|
||||
let Ok(content) = std::fs::read_to_string(root.join(".git").join("config")) else {
|
||||
return vec![];
|
||||
|
|
@ -197,7 +198,8 @@ pub async fn project_picker(State(state): State<AppState>) -> Result<Html<String
|
|||
.collect();
|
||||
|
||||
// Backlog — optional NCL export
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&proj.root, "reflection/backlog.ncl");
|
||||
let (backlog_items, backlog_open) = if backlog_path.exists() {
|
||||
match proj
|
||||
.cache
|
||||
|
|
@ -233,7 +235,8 @@ pub async fn project_picker(State(state): State<AppState>) -> Result<Html<String
|
|||
};
|
||||
|
||||
// Manifest — layers, operational_modes, default_mode, repo_kind, registry
|
||||
let manifest_path = ontoref_ontology::layout::resolve_section(&proj.root, "ontology/manifest.ncl");
|
||||
let manifest_path =
|
||||
ontoref_ontology::layout::resolve_section(&proj.root, "ontology/manifest.ncl");
|
||||
let manifest_json: Option<serde_json::Value> = if manifest_path.exists() {
|
||||
proj.cache
|
||||
.export(&manifest_path, proj.import_path.as_deref())
|
||||
|
|
@ -319,7 +322,8 @@ pub async fn project_picker(State(state): State<AppState>) -> Result<Html<String
|
|||
"project card loaded"
|
||||
);
|
||||
|
||||
// Description — first meaningful text line from README.md (fallback when no card)
|
||||
// Description — first meaningful text line from README.md (fallback when no
|
||||
// card)
|
||||
let description = if card
|
||||
.get("tagline")
|
||||
.and_then(|v| v.as_str())
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ pub(crate) async fn load_bookmark_entries(
|
|||
root: &std::path::Path,
|
||||
import_path: Option<&str>,
|
||||
) -> Vec<serde_json::Value> {
|
||||
let bm_path = ontoref_ontology::layout::resolve_section(root, "reflection/search_bookmarks.ncl");
|
||||
let bm_path =
|
||||
ontoref_ontology::layout::resolve_section(root, "reflection/search_bookmarks.ncl");
|
||||
if !bm_path.exists() {
|
||||
return vec![];
|
||||
}
|
||||
|
|
@ -122,7 +123,8 @@ pub async fn search_bookmark_add(
|
|||
Json(body): Json<BookmarkAddRequest>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let (root, cache) = resolve_bookmark_ctx(&state, body.slug.as_deref());
|
||||
let bm_path = ontoref_ontology::layout::resolve_section(&root, "reflection/search_bookmarks.ncl");
|
||||
let bm_path =
|
||||
ontoref_ontology::layout::resolve_section(&root, "reflection/search_bookmarks.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&bm_path).await;
|
||||
if !bm_path.exists() {
|
||||
return (
|
||||
|
|
@ -180,7 +182,8 @@ pub async fn search_bookmark_delete(
|
|||
Json(body): Json<BookmarkDeleteRequest>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let (root, cache) = resolve_bookmark_ctx(&state, body.slug.as_deref());
|
||||
let bm_path = ontoref_ontology::layout::resolve_section(&root, "reflection/search_bookmarks.ncl");
|
||||
let bm_path =
|
||||
ontoref_ontology::layout::resolve_section(&root, "reflection/search_bookmarks.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&bm_path).await;
|
||||
if !bm_path.exists() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||
use tera::Context;
|
||||
|
||||
use super::super::auth::AuthUser;
|
||||
use super::common::{
|
||||
auth_role_str, epoch_secs, insert_brand_ctx, render, tera_ref, UiError,
|
||||
};
|
||||
use super::common::{auth_role_str, epoch_secs, insert_brand_ctx, render, tera_ref, UiError};
|
||||
use crate::api::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -320,7 +318,8 @@ pub async fn notification_action_mp(
|
|||
.ok_or_else(|| {
|
||||
UiError::BadRequest("backlog_approve: missing proposed_status".into())
|
||||
})?;
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(&ctx_ref.root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(&ctx_ref.root, "reflection/backlog.ncl");
|
||||
let _guard = state.ncl_write_lock.acquire(&backlog_path).await;
|
||||
super::super::backlog_ncl::update_status(
|
||||
&backlog_path,
|
||||
|
|
|
|||
|
|
@ -55,10 +55,7 @@ impl TemplateWatcher {
|
|||
}
|
||||
}
|
||||
|
||||
async fn reload_loop(
|
||||
mut rx: mpsc::Receiver<PathBuf>,
|
||||
tera: Arc<RwLock<ui::TeraEnv>>,
|
||||
) {
|
||||
async fn reload_loop(mut rx: mpsc::Receiver<PathBuf>, tera: Arc<RwLock<ui::TeraEnv>>) {
|
||||
let debounce = Duration::from_millis(150);
|
||||
|
||||
loop {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
//! read path, shared by the HTTP API and the MCP surface so an interface IS a
|
||||
//! mounted view (not bespoke code). Mirrors the deterministic fold of the
|
||||
//! Nushell resolver (`reflection/modules/view.nu`): the declared prior ⊕ the
|
||||
//! active adjustment chain. The Nushell path remains the source of truth and the
|
||||
//! offline fallback (ADR-029). View files and the adjustment log are read as JSON
|
||||
//! `Value` so the schema is not re-declared in Rust — only the fold is, pinned by
|
||||
//! a unit test against the Nushell semantics.
|
||||
//! active adjustment chain. The Nushell path remains the source of truth and
|
||||
//! the offline fallback (ADR-029). View files and the adjustment log are read
|
||||
//! as JSON `Value` so the schema is not re-declared in Rust — only the fold is,
|
||||
//! pinned by a unit test against the Nushell semantics.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
|
@ -26,8 +26,8 @@ fn adjustments_path(root: &Path) -> PathBuf {
|
|||
root.join(".coder").join("view-adjustments.jsonl")
|
||||
}
|
||||
|
||||
/// Export one view NCL file to a JSON `Value` (relative imports resolve from the
|
||||
/// file's directory; the import path is supplied for any bare imports).
|
||||
/// Export one view NCL file to a JSON `Value` (relative imports resolve from
|
||||
/// the file's directory; the import path is supplied for any bare imports).
|
||||
fn export_ncl(path: &Path, import_path: Option<&str>) -> Option<Value> {
|
||||
let mut cmd = Command::new("nickel");
|
||||
cmd.args(["export", "--format", "json"]).arg(path);
|
||||
|
|
@ -104,7 +104,10 @@ fn compose(view: &Value, adjustments: &[Value]) -> Value {
|
|||
.iter()
|
||||
.map(|l| {
|
||||
let lref = l.get("ref").and_then(Value::as_str).unwrap_or("");
|
||||
let elig = l.get("eligibility").and_then(Value::as_str).unwrap_or("Always");
|
||||
let elig = l
|
||||
.get("eligibility")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Always");
|
||||
let base = l.get("salience").and_then(Value::as_i64).unwrap_or(0);
|
||||
let d = if elig == "Learned" {
|
||||
delta_for(&active, lref)
|
||||
|
|
@ -143,14 +146,16 @@ fn compose(view: &Value, adjustments: &[Value]) -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
/// Why a discipline could not be mounted — mapped to HTTP status / MCP error by callers.
|
||||
/// Why a discipline could not be mounted — mapped to HTTP status / MCP error by
|
||||
/// callers.
|
||||
pub enum MountError {
|
||||
NotFound(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
/// Declared substrate views (id, discipline, context, learns_from) as JSON.
|
||||
/// Shared by the HTTP `views_list` handler and the MCP `ontoref_view_list` tool.
|
||||
/// Shared by the HTTP `views_list` handler and the MCP `ontoref_view_list`
|
||||
/// tool.
|
||||
pub fn list_views_json(root: &Path, import_path: Option<&str>) -> Value {
|
||||
let rows: Vec<Value> = load_views(root, import_path)
|
||||
.iter()
|
||||
|
|
@ -213,10 +218,7 @@ fn resolve_root_for_slug<'a>(
|
|||
std::borrow::Cow::Borrowed(state.project_root.as_path())
|
||||
}
|
||||
|
||||
fn resolve_import_path_for_slug(
|
||||
state: &AppState,
|
||||
slug: Option<&str>,
|
||||
) -> Option<String> {
|
||||
fn resolve_import_path_for_slug(state: &AppState, slug: Option<&str>) -> Option<String> {
|
||||
if let Some(s) = slug {
|
||||
if let Some(ctx) = state.registry.get(s) {
|
||||
return ctx.import_path.clone();
|
||||
|
|
@ -225,7 +227,8 @@ fn resolve_import_path_for_slug(
|
|||
state.nickel_import_path.clone()
|
||||
}
|
||||
|
||||
/// Declared substrate views. Accepts optional `?slug=` to query a registry project's views.
|
||||
/// Declared substrate views. Accepts optional `?slug=` to query a registry
|
||||
/// project's views.
|
||||
#[ontoref_derive::onto_api(
|
||||
method = "GET",
|
||||
path = "/api/views",
|
||||
|
|
@ -233,10 +236,7 @@ fn resolve_import_path_for_slug(
|
|||
actors = "agent, developer, ci, admin",
|
||||
tags = "views, substrate"
|
||||
)]
|
||||
pub async fn views_list(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SlugQuery>,
|
||||
) -> Json<Value> {
|
||||
pub async fn views_list(State(state): State<AppState>, Query(q): Query<SlugQuery>) -> Json<Value> {
|
||||
let root = resolve_root_for_slug(&state, q.slug.as_deref());
|
||||
let import_path = resolve_import_path_for_slug(&state, q.slug.as_deref());
|
||||
Json(list_views_json(&root, import_path.as_deref()))
|
||||
|
|
@ -266,7 +266,9 @@ pub async fn views_mount(
|
|||
)),
|
||||
Err(MountError::Multiple(contexts)) => Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": "multiple views for discipline; specify context", "contexts": contexts })),
|
||||
Json(
|
||||
json!({ "error": "multiple views for discipline; specify context", "contexts": contexts }),
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -516,10 +516,7 @@ fn discover_pending_migrations(project_root: &Path) -> Vec<String> {
|
|||
/// Run `nickel export --format json` against `config_path` with the given
|
||||
/// import path. Returns the parsed JSON or `None` on any failure (with a
|
||||
/// `warn!` log emitted by the caller's diagnostic context).
|
||||
fn export_config_json(
|
||||
config_path: &Path,
|
||||
import_path: Option<&str>,
|
||||
) -> Option<serde_json::Value> {
|
||||
fn export_config_json(config_path: &Path, import_path: Option<&str>) -> Option<serde_json::Value> {
|
||||
let mut cmd = std::process::Command::new("nickel");
|
||||
cmd.args(["export", "--format", "json"]).arg(config_path);
|
||||
if let Some(ip) = import_path {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
//! the resulting witnesses match on every cross-surface invariant
|
||||
//! (op_id, witness_shape, state_root, signature, effects).
|
||||
//!
|
||||
//! - HTTP: in-process axum via `tower::ServiceExt::oneshot` — no
|
||||
//! live socket required.
|
||||
//! - HTTP: in-process axum via `tower::ServiceExt::oneshot` — no live socket
|
||||
//! required.
|
||||
//! - CLI: spawns the compiled daemon binary via
|
||||
//! `env!("CARGO_BIN_EXE_ontoref-daemon")`.
|
||||
//! - MCP: calls the pure-function projection that the MCP `tools/list`
|
||||
//! response is built from.
|
||||
//! - MCP: calls the pure-function projection that the MCP `tools/list` response
|
||||
//! is built from.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
|
|
@ -107,11 +107,13 @@ async fn http_surface_dispatches_propose_adr() {
|
|||
let body_bytes = axum::body::to_bytes(response.into_body(), 16 * 1024)
|
||||
.await
|
||||
.expect("collect body");
|
||||
let response: DispatchResponse =
|
||||
serde_json::from_slice(&body_bytes).expect("response parses");
|
||||
let response: DispatchResponse = serde_json::from_slice(&body_bytes).expect("response parses");
|
||||
assert_eq!(response.op_id, "propose_adr");
|
||||
assert_eq!(response.witness_shape, "adr_proposed");
|
||||
assert!(tmp.path().join(".ontoref/adrs/adr-502-surface-equivalence.ncl").exists());
|
||||
assert!(tmp
|
||||
.path()
|
||||
.join(".ontoref/adrs/adr-502-surface-equivalence.ncl")
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -155,7 +157,10 @@ fn cli_surface_dispatches_propose_adr() {
|
|||
let response: DispatchResponse =
|
||||
serde_json::from_str(&stdout).expect("stdout parses as DispatchResponse");
|
||||
assert_eq!(response.op_id, "propose_adr");
|
||||
assert!(tmp.path().join(".ontoref/adrs/adr-503-cli-test.ncl").exists());
|
||||
assert!(tmp
|
||||
.path()
|
||||
.join(".ontoref/adrs/adr-503-cli-test.ncl")
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -224,7 +229,10 @@ fn function_cli_and_http_produce_equivalent_witnesses() {
|
|||
assert_eq!(fn_response.state_root_hex, cli_response.state_root_hex);
|
||||
assert_eq!(fn_response.signature_hex, cli_response.signature_hex);
|
||||
assert_eq!(fn_response.effects_hash_hex, cli_response.effects_hash_hex);
|
||||
assert_eq!(fn_response.actor_public_key_hex, cli_response.actor_public_key_hex);
|
||||
assert_eq!(
|
||||
fn_response.actor_public_key_hex,
|
||||
cli_response.actor_public_key_hex
|
||||
);
|
||||
}
|
||||
|
||||
/// Inline copies of the daemon's HTTP handlers — strip the AppState
|
||||
|
|
|
|||
|
|
@ -98,10 +98,7 @@ fn apply_diffs_dispatches_via_runtime() {
|
|||
let result = &results[0];
|
||||
assert_eq!(result.witness.op_id, "move_fsm_state");
|
||||
result.witness.verify().expect("witness verifies");
|
||||
assert_eq!(
|
||||
ctx.state.as_ref().unwrap().dimensions[0].current_state,
|
||||
"b"
|
||||
);
|
||||
assert_eq!(ctx.state.as_ref().unwrap().dimensions[0].current_state, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -132,17 +129,17 @@ fn apply_diffs_rolls_back_on_uncatalogued_edit() {
|
|||
let err = apply_diffs(&diffs, &mut ctx).expect_err("uncatalogued edit must reject");
|
||||
match err {
|
||||
ReconcileError::UncatalogedDiff(msg) => {
|
||||
assert!(msg.contains("new-dim"), "diff summary must reference the new dim: {msg}");
|
||||
assert!(
|
||||
msg.contains("new-dim"),
|
||||
"diff summary must reference the new dim: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected UncatalogedDiff, got {other:?}"),
|
||||
}
|
||||
|
||||
// State left untouched — first-pass refusal preserves the
|
||||
// pre-edit substrate.
|
||||
assert_eq!(
|
||||
ctx.state.as_ref().unwrap().dimensions[0].current_state,
|
||||
"a"
|
||||
);
|
||||
assert_eq!(ctx.state.as_ref().unwrap().dimensions[0].current_state, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1094,7 +1094,8 @@ fn expand_onto_operation(
|
|||
return Err(syn::Error::new_spanned(
|
||||
&kv.value,
|
||||
format!(
|
||||
"unknown validation_sla '{other}'; expected Synchronous | EventualWithin | Background"
|
||||
"unknown validation_sla '{other}'; expected Synchronous | \
|
||||
EventualWithin | Background"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
@ -1124,7 +1125,8 @@ fn expand_onto_operation(
|
|||
let validation_sla = validation_sla.ok_or_else(|| {
|
||||
syn::Error::new(
|
||||
Span::call_site(),
|
||||
"#[onto_operation] requires validation_sla = \"Synchronous\" | \"EventualWithin\" | \"Background\"",
|
||||
"#[onto_operation] requires validation_sla = \"Synchronous\" | \"EventualWithin\" | \
|
||||
\"Background\"",
|
||||
)
|
||||
})?;
|
||||
let desc = description.or(doc_desc).ok_or_else(|| {
|
||||
|
|
@ -1152,7 +1154,8 @@ fn expand_onto_operation(
|
|||
let fn_name = fn_ident_name.ok_or_else(|| {
|
||||
syn::Error::new(
|
||||
Span::call_site(),
|
||||
"#[onto_operation] must be applied to a function — the function pointer is used as the op's execute field",
|
||||
"#[onto_operation] must be applied to a function — the function pointer is used as \
|
||||
the op's execute field",
|
||||
)
|
||||
})?;
|
||||
Ok(emit_onto_operation(attr, &fn_name, input))
|
||||
|
|
@ -1299,7 +1302,9 @@ fn expand_onto_validator(
|
|||
let doc_desc: Option<String> = parsed_fn
|
||||
.as_ref()
|
||||
.and_then(|fn_item| fn_item.attrs.iter().cloned().find_map(doc_attr_text));
|
||||
let fn_name: Option<String> = parsed_fn.as_ref().map(|fn_item| fn_item.sig.ident.to_string());
|
||||
let fn_name: Option<String> = parsed_fn
|
||||
.as_ref()
|
||||
.map(|fn_item| fn_item.sig.ident.to_string());
|
||||
|
||||
let kv_args = syn::parse::Parser::parse2(
|
||||
Punctuated::<MetaNameValue, Token![,]>::parse_terminated,
|
||||
|
|
@ -1330,9 +1335,7 @@ fn expand_onto_validator(
|
|||
other => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
&kv.value,
|
||||
format!(
|
||||
"unknown category '{other}'; expected Structural | Contextual"
|
||||
),
|
||||
format!("unknown category '{other}'; expected Structural | Contextual"),
|
||||
));
|
||||
}
|
||||
},
|
||||
|
|
@ -1366,7 +1369,8 @@ fn expand_onto_validator(
|
|||
let predicate_ref = predicate_ref.or(fn_name).ok_or_else(|| {
|
||||
syn::Error::new(
|
||||
Span::call_site(),
|
||||
"#[onto_validator] could not infer predicate_ref — supply it explicitly when applied to a non-fn item",
|
||||
"#[onto_validator] could not infer predicate_ref — supply it explicitly when applied \
|
||||
to a non-fn item",
|
||||
)
|
||||
})?;
|
||||
|
||||
|
|
@ -1427,9 +1431,10 @@ fn emit_onto_validator(
|
|||
mod tests {
|
||||
mod onto_operation {
|
||||
mod expand {
|
||||
use crate::expand_onto_operation;
|
||||
use quote::quote;
|
||||
|
||||
use crate::expand_onto_operation;
|
||||
|
||||
fn must_expand(
|
||||
args: proc_macro2::TokenStream,
|
||||
input: proc_macro2::TokenStream,
|
||||
|
|
@ -1461,10 +1466,7 @@ mod tests {
|
|||
);
|
||||
assert!(s.contains("\"test_op\""), "must embed the op id");
|
||||
assert!(s.contains("\"minimal test\""), "must embed description");
|
||||
assert!(
|
||||
s.contains("\"Synchronous\""),
|
||||
"must embed validation_sla"
|
||||
);
|
||||
assert!(s.contains("\"Synchronous\""), "must embed validation_sla");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1542,9 +1544,10 @@ mod tests {
|
|||
|
||||
mod onto_validator {
|
||||
mod expand {
|
||||
use crate::expand_onto_validator;
|
||||
use quote::quote;
|
||||
|
||||
use crate::expand_onto_validator;
|
||||
|
||||
fn must_expand(
|
||||
args: proc_macro2::TokenStream,
|
||||
input: proc_macro2::TokenStream,
|
||||
|
|
@ -1622,7 +1625,8 @@ mod tests {
|
|||
description = "y",
|
||||
category = "Probabilistic"
|
||||
};
|
||||
let input = quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
||||
let input =
|
||||
quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
||||
let err = expand_onto_validator(args, input).expect_err("bad category rejected");
|
||||
assert!(err.to_string().contains("unknown category"));
|
||||
}
|
||||
|
|
@ -1635,7 +1639,8 @@ mod tests {
|
|||
category = "Structural",
|
||||
bogus = "z"
|
||||
};
|
||||
let input = quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
||||
let input =
|
||||
quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
||||
let err = expand_onto_validator(args, input).expect_err("unknown key rejected");
|
||||
assert!(err.to_string().contains("unknown onto_validator key"));
|
||||
}
|
||||
|
|
@ -1643,7 +1648,8 @@ mod tests {
|
|||
#[test]
|
||||
fn requires_id_and_category() {
|
||||
let args = quote! { description = "y" };
|
||||
let input = quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
||||
let input =
|
||||
quote! { fn h(_: &Slice, _: &ValidationCtx) -> Verdict { Verdict::Accept } };
|
||||
let err = expand_onto_validator(args, input).expect_err("missing id");
|
||||
assert!(err.to_string().contains("requires id"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,18 @@
|
|||
//! Four types govern how an ontology is shared and verified across
|
||||
//! actors. Each type carries different governance and sync expectations:
|
||||
//!
|
||||
//! - **Type 1 (Protocol)** — mandatorily decentralized; ontoref-core is
|
||||
//! the first extracted. Every actor MUST be able to operate without
|
||||
//! relying on a central authority for the ontology's content.
|
||||
//! - **Type 1 (Protocol)** — mandatorily decentralized; ontoref-core is the
|
||||
//! first extracted. Every actor MUST be able to operate without relying on a
|
||||
//! central authority for the ontology's content.
|
||||
//! - **Type 2a (Shared-Domain Federable)** — shared across projects but
|
||||
//! federable: a maintainer publishes the canonical version while
|
||||
//! forks may diverge with their own ids.
|
||||
//! - **Type 2b (Shared-Domain Mandatorily Decentralized)** — shared
|
||||
//! across projects and mandatorily decentralized: no canonical
|
||||
//! maintainer; the address itself is the authority.
|
||||
//! federable: a maintainer publishes the canonical version while forks may
|
||||
//! diverge with their own ids.
|
||||
//! - **Type 2b (Shared-Domain Mandatorily Decentralized)** — shared across
|
||||
//! projects and mandatorily decentralized: no canonical maintainer; the
|
||||
//! address itself is the authority.
|
||||
//! - **Type 3 (Project-Specific)** — opt-in per project; each project's
|
||||
//! self-description lives here.
|
||||
//! - **Type 4 (Private)** — tier-0; never leaves the actor's local
|
||||
//! environment.
|
||||
//! - **Type 4 (Private)** — tier-0; never leaves the actor's local environment.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -61,7 +60,10 @@ impl OntologyType {
|
|||
|
||||
/// Whether this type forbids any centralised authority.
|
||||
pub fn must_be_decentralized(&self) -> bool {
|
||||
matches!(self, Self::Type1Protocol | Self::Type2bMandatoryDecentralized)
|
||||
matches!(
|
||||
self,
|
||||
Self::Type1Protocol | Self::Type2bMandatoryDecentralized
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether instances of this type are allowed to leave the actor.
|
||||
|
|
|
|||
|
|
@ -64,21 +64,12 @@ pub fn extract_core_to_oplog(
|
|||
|
||||
if let Some(nodes) = core_json.get("nodes").and_then(|v| v.as_array()) {
|
||||
for node in nodes {
|
||||
let entity_id = node
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let entity_id = node.get("id").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
if entity_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let attrs = build_attrs_from_object(node);
|
||||
let op = build_signed_op(
|
||||
signing_key,
|
||||
&pubkey,
|
||||
entity_id,
|
||||
attrs,
|
||||
Hlc::new(next_ts, 0),
|
||||
);
|
||||
let op = build_signed_op(signing_key, &pubkey, entity_id, attrs, Hlc::new(next_ts, 0));
|
||||
log.append(&op).map_err(Error::Append)?;
|
||||
next_ts += 1;
|
||||
nodes_extracted += 1;
|
||||
|
|
@ -137,14 +128,16 @@ pub fn write_ontology_refs(
|
|||
}
|
||||
|
||||
let entry = format!(
|
||||
" {{\n name = \"{name}\",\n ontology_id = \"{}\",\n oplog_path = \"{}\",\n classification = '{},\n }},\n",
|
||||
" {{\n name = \"{name}\",\n ontology_id = \"{}\",\n oplog_path = \
|
||||
\"{}\",\n classification = '{},\n }},\n",
|
||||
ontology_id.to_hex(),
|
||||
oplog_path.display(),
|
||||
classification.tag(),
|
||||
);
|
||||
|
||||
let content = format!(
|
||||
"# Auto-generated by `ontoref-daemon --extract-ontology`.\n# Re-run extraction to update; do not edit by hand.\n\n{{\n ontology_refs = [\n{entry} ],\n}}\n"
|
||||
"# Auto-generated by `ontoref-daemon --extract-ontology`.\n# Re-run extraction to update; \
|
||||
do not edit by hand.\n\n{{\n ontology_refs = [\n{entry} ],\n}}\n"
|
||||
);
|
||||
|
||||
std::fs::write(refs_path, content).map_err(|e| Error::Io {
|
||||
|
|
@ -212,7 +205,9 @@ fn build_signed_op(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{fetch_cell_witness, verify_cell, CellQuery, OntologyRegistry, OntologyType, RegistryEntry};
|
||||
use crate::{
|
||||
fetch_cell_witness, verify_cell, CellQuery, OntologyRegistry, OntologyType, RegistryEntry,
|
||||
};
|
||||
|
||||
fn signing_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[7u8; 32])
|
||||
|
|
@ -233,15 +228,18 @@ mod tests {
|
|||
],
|
||||
});
|
||||
|
||||
let stats = extract_core_to_oplog(&core_json, &oplog_path, &signing_key())
|
||||
.expect("extract ok");
|
||||
let stats =
|
||||
extract_core_to_oplog(&core_json, &oplog_path, &signing_key()).expect("extract ok");
|
||||
assert_eq!(stats.nodes_extracted, 2);
|
||||
assert_eq!(stats.edges_extracted, 1);
|
||||
|
||||
// Idempotent: second extraction adds nothing.
|
||||
let stats2 = extract_core_to_oplog(&core_json, &oplog_path, &signing_key())
|
||||
.expect("second extract ok");
|
||||
assert_eq!(stats2.ontology_id, stats.ontology_id, "idempotent extraction must produce the same id");
|
||||
assert_eq!(
|
||||
stats2.ontology_id, stats.ontology_id,
|
||||
"idempotent extraction must produce the same id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -256,8 +254,8 @@ mod tests {
|
|||
"edges": [],
|
||||
});
|
||||
|
||||
let stats = extract_core_to_oplog(&core_json, &oplog_path, &signing_key())
|
||||
.expect("extract ok");
|
||||
let stats =
|
||||
extract_core_to_oplog(&core_json, &oplog_path, &signing_key()).expect("extract ok");
|
||||
|
||||
let mut registry = OntologyRegistry::empty();
|
||||
registry.register(RegistryEntry {
|
||||
|
|
@ -269,8 +267,7 @@ mod tests {
|
|||
|
||||
// Project A can fetch the cell witness for protocol-not-runtime/name.
|
||||
let query = CellQuery::new("protocol-not-runtime", "name");
|
||||
let response =
|
||||
fetch_cell_witness(®istry, &stats.ontology_id, &query).expect("fetch ok");
|
||||
let response = fetch_cell_witness(®istry, &stats.ontology_id, &query).expect("fetch ok");
|
||||
assert!(verify_cell(&response.witness, &response.state_root));
|
||||
}
|
||||
|
||||
|
|
@ -281,8 +278,14 @@ mod tests {
|
|||
let oplog_path = tmp.path().join("ontoref-core.oplog");
|
||||
let id = OntologyId([0xAB; 32]);
|
||||
|
||||
write_ontology_refs(&refs, "ontoref-core", &id, &oplog_path, OntologyType::Type1Protocol)
|
||||
.expect("write ok");
|
||||
write_ontology_refs(
|
||||
&refs,
|
||||
"ontoref-core",
|
||||
&id,
|
||||
&oplog_path,
|
||||
OntologyType::Type1Protocol,
|
||||
)
|
||||
.expect("write ok");
|
||||
|
||||
let content = std::fs::read_to_string(&refs).expect("read ok");
|
||||
assert!(content.contains("ontoref-core"));
|
||||
|
|
|
|||
|
|
@ -82,12 +82,9 @@ pub struct OntologyHandle {
|
|||
///
|
||||
/// - [`Error::UnknownOntology`] when `id` is not registered locally.
|
||||
/// - [`Error::OpenOplog`] when the oplog file cannot be opened.
|
||||
/// - [`Error::Append`] when the oplog returns an internal error while
|
||||
/// replaying operations.
|
||||
pub fn fetch_ontology(
|
||||
registry: &OntologyRegistry,
|
||||
id: &OntologyId,
|
||||
) -> Result<OntologyHandle> {
|
||||
/// - [`Error::Append`] when the oplog returns an internal error while replaying
|
||||
/// operations.
|
||||
pub fn fetch_ontology(registry: &OntologyRegistry, id: &OntologyId) -> Result<OntologyHandle> {
|
||||
let entry = resolve(registry, id)?;
|
||||
let state_root = compute_state_root(&entry.oplog_path)?;
|
||||
Ok(OntologyHandle {
|
||||
|
|
@ -110,10 +107,10 @@ pub fn fetch_ontology(
|
|||
///
|
||||
/// - [`Error::UnknownOntology`] when `id` is not registered locally.
|
||||
/// - [`Error::OpenOplog`] when the oplog file cannot be opened.
|
||||
/// - [`Error::CellMissing`] when the cell `(entity, attr)` has never
|
||||
/// been asserted under the ontology's commit layer.
|
||||
/// - [`Error::Append`] when the oplog returns an internal error while
|
||||
/// replaying operations.
|
||||
/// - [`Error::CellMissing`] when the cell `(entity, attr)` has never been
|
||||
/// asserted under the ontology's commit layer.
|
||||
/// - [`Error::Append`] when the oplog returns an internal error while replaying
|
||||
/// operations.
|
||||
pub fn fetch_cell_witness(
|
||||
registry: &OntologyRegistry,
|
||||
id: &OntologyId,
|
||||
|
|
@ -135,10 +132,7 @@ pub fn fetch_cell_witness(
|
|||
})
|
||||
}
|
||||
|
||||
fn resolve<'r>(
|
||||
registry: &'r OntologyRegistry,
|
||||
id: &OntologyId,
|
||||
) -> Result<&'r RegistryEntry> {
|
||||
fn resolve<'r>(registry: &'r OntologyRegistry, id: &OntologyId) -> Result<&'r RegistryEntry> {
|
||||
registry
|
||||
.lookup(id)
|
||||
.ok_or_else(|| Error::UnknownOntology(id.to_hex()))
|
||||
|
|
@ -146,9 +140,7 @@ fn resolve<'r>(
|
|||
|
||||
fn rebuild_commit_layer(oplog_path: &PathBuf) -> Result<BinaryMerkle> {
|
||||
let log = open_oplog(oplog_path)?;
|
||||
let ops: Vec<Operation> = log
|
||||
.all_ops_in_hlc_order()
|
||||
.map_err(Error::Append)?;
|
||||
let ops: Vec<Operation> = log.all_ops_in_hlc_order().map_err(Error::Append)?;
|
||||
let mut commit = BinaryMerkle::new();
|
||||
for op in &ops {
|
||||
commit.apply(op);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ pub mod verify;
|
|||
|
||||
pub use classification::OntologyType;
|
||||
pub use extract::{extract_core_to_oplog, write_ontology_refs, ExtractStats};
|
||||
pub use fetch::{fetch_cell_witness, fetch_ontology, CellQuery, CellWitnessResponse, OntologyHandle};
|
||||
pub use fetch::{
|
||||
fetch_cell_witness, fetch_ontology, CellQuery, CellWitnessResponse, OntologyHandle,
|
||||
};
|
||||
pub use ontology_id::{compute_ontology_id, OntologyId};
|
||||
pub use publish::publish_op;
|
||||
pub use registry::{OntologyRegistry, RegistryEntry};
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ use crate::{Error, Result};
|
|||
/// # Errors
|
||||
///
|
||||
/// - [`Error::OpenOplog`] when the oplog file cannot be opened.
|
||||
/// - [`Error::Append`] when the oplog rejects the operation (bad
|
||||
/// signature, unknown parent, non-monotonic timestamp, IO).
|
||||
/// - [`Error::Append`] when the oplog rejects the operation (bad signature,
|
||||
/// unknown parent, non-monotonic timestamp, IO).
|
||||
pub fn publish_op(oplog_path: &Path, op: &Operation) -> Result<OpId> {
|
||||
let log = OpLog::open(oplog_path).map_err(|e| Error::OpenOplog {
|
||||
path: oplog_path.display().to_string(),
|
||||
|
|
|
|||
|
|
@ -50,17 +50,16 @@ impl OntologyRegistry {
|
|||
/// # Errors
|
||||
///
|
||||
/// - [`Error::Io`] when the file exists but cannot be read.
|
||||
/// - [`Error::RegistryParse`] when the file exists but does not
|
||||
/// parse into the registry schema.
|
||||
/// - [`Error::RegistryParse`] when the file exists but does not parse into
|
||||
/// the registry schema.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let reg = serde_json::from_slice::<Self>(&bytes).map_err(|e| {
|
||||
Error::RegistryParse {
|
||||
let reg =
|
||||
serde_json::from_slice::<Self>(&bytes).map_err(|e| Error::RegistryParse {
|
||||
path: path.display().to_string(),
|
||||
message: e.to_string(),
|
||||
}
|
||||
})?;
|
||||
})?;
|
||||
Ok(reg)
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||
|
|
@ -102,7 +101,11 @@ impl OntologyRegistry {
|
|||
/// Register a new entry, replacing any existing one with the same
|
||||
/// ontology id.
|
||||
pub fn register(&mut self, entry: RegistryEntry) {
|
||||
if let Some(existing) = self.entries.iter_mut().find(|e| e.ontology_id == entry.ontology_id) {
|
||||
if let Some(existing) = self
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.ontology_id == entry.ontology_id)
|
||||
{
|
||||
*existing = entry;
|
||||
} else {
|
||||
self.entries.push(entry);
|
||||
|
|
|
|||
|
|
@ -13,17 +13,16 @@
|
|||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_commit::BinaryMerkle;
|
||||
use ontoref_ontology_content::{
|
||||
compute_ontology_id, fetch_cell_witness, verify_cell, CellQuery, Error, OntologyRegistry,
|
||||
OntologyType, RegistryEntry,
|
||||
};
|
||||
use ontoref_oplog::OpLog;
|
||||
use ontoref_types::{
|
||||
operation::{OpBody, OpPayload, Operation},
|
||||
AttrId, EntityId, Hlc, PublicKey, Value,
|
||||
};
|
||||
|
||||
use ontoref_ontology_content::{
|
||||
compute_ontology_id, fetch_cell_witness, verify_cell, CellQuery, Error, OntologyRegistry,
|
||||
OntologyType, RegistryEntry,
|
||||
};
|
||||
|
||||
fn signing_key(seed: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[seed; 32])
|
||||
}
|
||||
|
|
@ -45,7 +44,10 @@ fn assert_op(entity: &str, attr: &str, value: Value, ts_logical: u64, seed: u8)
|
|||
|
||||
/// Append `ops` to a fresh oplog at `path` and return the resulting
|
||||
/// [`OntologyId`] computed from the oplog's heads.
|
||||
fn build_ontology(path: &std::path::Path, ops: &[Operation]) -> ontoref_ontology_content::OntologyId {
|
||||
fn build_ontology(
|
||||
path: &std::path::Path,
|
||||
ops: &[Operation],
|
||||
) -> ontoref_ontology_content::OntologyId {
|
||||
let log = OpLog::open(path).expect("open oplog");
|
||||
for op in ops {
|
||||
log.append(op).expect("append op");
|
||||
|
|
@ -61,20 +63,8 @@ fn fetch_cell_witness_returns_only_witness_and_root() {
|
|||
// ── Foreign ontology X — carries an "x-entity" cell. ────────────────────
|
||||
let x_oplog = tmp.path().join("x.oplog");
|
||||
let x_ops = vec![
|
||||
assert_op(
|
||||
"x-entity",
|
||||
"x-attr",
|
||||
Value::Str("x-value".into()),
|
||||
1,
|
||||
0xAA,
|
||||
),
|
||||
assert_op(
|
||||
"x-entity-other",
|
||||
"x-attr-other",
|
||||
Value::Int(42),
|
||||
2,
|
||||
0xAA,
|
||||
),
|
||||
assert_op("x-entity", "x-attr", Value::Str("x-value".into()), 1, 0xAA),
|
||||
assert_op("x-entity-other", "x-attr-other", Value::Int(42), 2, 0xAA),
|
||||
];
|
||||
let x_id = build_ontology(&x_oplog, &x_ops);
|
||||
|
||||
|
|
@ -116,8 +106,8 @@ fn fetch_cell_witness_returns_only_witness_and_root() {
|
|||
);
|
||||
|
||||
// The witness contains the canonical encoding of "x-value".
|
||||
let expected = ontoref_types::canonical::encode(&Value::Str("x-value".into()))
|
||||
.expect("encode value");
|
||||
let expected =
|
||||
ontoref_types::canonical::encode(&Value::Str("x-value".into())).expect("encode value");
|
||||
assert_eq!(response.witness.value, expected);
|
||||
|
||||
// ── Negative: the witness must NOT verify under Y's state root. ────────
|
||||
|
|
@ -130,7 +120,10 @@ fn fetch_cell_witness_returns_only_witness_and_root() {
|
|||
y_commit.apply(op);
|
||||
}
|
||||
let y_root = y_commit.root();
|
||||
assert_ne!(response.state_root, y_root, "X and Y must have different roots");
|
||||
assert_ne!(
|
||||
response.state_root, y_root,
|
||||
"X and Y must have different roots"
|
||||
);
|
||||
assert!(
|
||||
!verify_cell(&response.witness, &y_root),
|
||||
"witness from X must not verify under Y's state root"
|
||||
|
|
@ -144,7 +137,8 @@ fn fetch_cell_witness_returns_only_witness_and_root() {
|
|||
let full_oplog_size_bound = x_ops.len() * 1024; // pessimistic per-op size
|
||||
assert!(
|
||||
response_size < full_oplog_size_bound,
|
||||
"response must be bounded — full oplog leakage would exceed {full_oplog_size_bound} bytes; got {response_size}"
|
||||
"response must be bounded — full oplog leakage would exceed {full_oplog_size_bound} \
|
||||
bytes; got {response_size}"
|
||||
);
|
||||
|
||||
// Fetching a cell that does not exist in X returns CellMissing,
|
||||
|
|
@ -159,10 +153,7 @@ fn fetch_cell_witness_returns_only_witness_and_root() {
|
|||
fn fetch_unknown_ontology_id_returns_error() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let oplog = tmp.path().join("x.oplog");
|
||||
let _ = build_ontology(
|
||||
&oplog,
|
||||
&[assert_op("e", "a", Value::Int(1), 1, 0xCC)],
|
||||
);
|
||||
let _ = build_ontology(&oplog, &[assert_op("e", "a", Value::Int(1), 1, 0xCC)]);
|
||||
|
||||
let registry = OntologyRegistry::empty();
|
||||
let unknown_id = ontoref_ontology_content::OntologyId([0xFFu8; 32]);
|
||||
|
|
|
|||
|
|
@ -291,10 +291,12 @@ pub fn is_legacy_layout(project_root: &Path) -> bool {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_canonical_when_both_exist() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
|
|
@ -370,7 +372,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn relative_in_section_matches_both_prefixes() {
|
||||
assert!(relative_in_section(".ontoref/ontology/core.ncl", "ontology"));
|
||||
assert!(relative_in_section(
|
||||
".ontoref/ontology/core.ncl",
|
||||
"ontology"
|
||||
));
|
||||
assert!(relative_in_section(".ontology/core.ncl", "ontology"));
|
||||
assert!(relative_in_section(".ontoref/adrs/adr-001.ncl", "adrs"));
|
||||
assert!(relative_in_section("adrs/adr-001.ncl", "adrs"));
|
||||
|
|
|
|||
|
|
@ -395,7 +395,8 @@ impl Gate {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Positioning (ADR-035) ─────────────────────────────────────────────────────
|
||||
// ── Positioning (ADR-035)
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
/// Runtime view of a project's positioning layer.
|
||||
///
|
||||
|
|
@ -611,19 +612,15 @@ impl Positioning {
|
|||
cfg.value_props =
|
||||
load_dir::<ValueProp>(&positioning_dir.join("value-props"), "value-prop")?;
|
||||
cfg.campaigns = load_dir::<Campaign>(&positioning_dir.join("campaigns"), "campaign")?;
|
||||
cfg.differentiators = load_dir::<Differentiator>(
|
||||
&positioning_dir.join("differentiators"),
|
||||
"differentiator",
|
||||
)?;
|
||||
cfg.differentiators =
|
||||
load_dir::<Differentiator>(&positioning_dir.join("differentiators"), "differentiator")?;
|
||||
cfg.competitors =
|
||||
load_dir::<Competitor>(&positioning_dir.join("competitors"), "competitor")?;
|
||||
cfg.mechanisms =
|
||||
load_dir::<DifusionMechanism>(&positioning_dir.join("difusion"), "mechanism")?;
|
||||
cfg.proofs = load_dir::<Proof>(&positioning_dir.join("proofs"), "proof")?;
|
||||
cfg.viability_paths = load_dir::<ViabilityPath>(
|
||||
&positioning_dir.join("viability"),
|
||||
"viability-path",
|
||||
)?;
|
||||
cfg.viability_paths =
|
||||
load_dir::<ViabilityPath>(&positioning_dir.join("viability"), "viability-path")?;
|
||||
|
||||
Self::from_config(cfg)
|
||||
}
|
||||
|
|
@ -673,9 +670,7 @@ impl Positioning {
|
|||
.map(|&i| &self.viability_paths[i])
|
||||
}
|
||||
pub fn value_prop_by_id(&self, id: &str) -> Option<&ValueProp> {
|
||||
self.value_prop_by_id
|
||||
.get(id)
|
||||
.map(|&i| &self.value_props[i])
|
||||
self.value_prop_by_id.get(id).map(|&i| &self.value_props[i])
|
||||
}
|
||||
pub fn campaign_by_id(&self, id: &str) -> Option<&Campaign> {
|
||||
self.campaign_by_id.get(id).map(|&i| &self.campaigns[i])
|
||||
|
|
@ -698,10 +693,7 @@ impl Positioning {
|
|||
/// e.g. `_template.ncl`, `_smoke-test.ncl`). A missing `dir` yields an
|
||||
/// empty Vec — sub-directories are independently optional within the
|
||||
/// positioning layer (a project may have audiences but no campaigns yet).
|
||||
fn load_dir<T: serde::de::DeserializeOwned>(
|
||||
dir: &Path,
|
||||
section: &'static str,
|
||||
) -> Result<Vec<T>> {
|
||||
fn load_dir<T: serde::de::DeserializeOwned>(dir: &Path, section: &'static str) -> Result<Vec<T>> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
|
@ -722,10 +714,8 @@ fn load_dir<T: serde::de::DeserializeOwned>(
|
|||
continue;
|
||||
}
|
||||
let raw = nickel_export(&path, section)?;
|
||||
let item: T = serde_json::from_slice(&raw).map_err(|e| OntologyError::Parse {
|
||||
section,
|
||||
source: e,
|
||||
})?;
|
||||
let item: T = serde_json::from_slice(&raw)
|
||||
.map_err(|e| OntologyError::Parse { section, source: e })?;
|
||||
items.push(item);
|
||||
}
|
||||
Ok(items)
|
||||
|
|
@ -1005,11 +995,7 @@ mod tests {
|
|||
let cfg = PositioningConfig {
|
||||
audiences: vec![mk_audience("aud-1")],
|
||||
value_props: vec![mk_value_prop("vp-1", vec!["aud-1"])],
|
||||
campaigns: vec![mk_campaign(
|
||||
"camp-1",
|
||||
vec!["aud-missing"],
|
||||
vec!["vp-1"],
|
||||
)],
|
||||
campaigns: vec![mk_campaign("camp-1", vec!["aud-missing"], vec!["vp-1"])],
|
||||
..Default::default()
|
||||
};
|
||||
let err = Positioning::from_config(cfg).expect_err("must reject");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Byte-stable renderers from authoritative Rust state to NCL files (ADR-025 / D9).
|
||||
//! Byte-stable renderers from authoritative Rust state to NCL files (ADR-025 /
|
||||
//! D9).
|
||||
//!
|
||||
//! Phase 1 covers `state.ncl` fully — the file consumed by the
|
||||
//! `move_fsm_state` operation (O3). Other renderable paths
|
||||
|
|
|
|||
|
|
@ -45,12 +45,11 @@ pub enum PipelineError {
|
|||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`PipelineError::Render`] — propagated from
|
||||
/// [`crate::render::render`] for unsupported paths or render failure.
|
||||
/// - [`PipelineError::TypecheckFailed`] — when `nickel typecheck`
|
||||
/// reports a contract or syntax error on the staged file.
|
||||
/// - [`PipelineError::NickelMissing`] — the `nickel` binary cannot be
|
||||
/// spawned.
|
||||
/// - [`PipelineError::Render`] — propagated from [`crate::render::render`] for
|
||||
/// unsupported paths or render failure.
|
||||
/// - [`PipelineError::TypecheckFailed`] — when `nickel typecheck` reports a
|
||||
/// contract or syntax error on the staged file.
|
||||
/// - [`PipelineError::NickelMissing`] — the `nickel` binary cannot be spawned.
|
||||
/// - [`PipelineError::Io`] — directory creation, write, or rename failed.
|
||||
pub fn render_and_write(state: &StateConfig, path: &Path) -> Result<(), PipelineError> {
|
||||
let bytes = render(state, path)?;
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ pub enum AbstractionLevel {
|
|||
Moment,
|
||||
}
|
||||
|
||||
/// Edge type between nodes. Reconciled 2026-07-09 (G-2) to the vocabulary actually
|
||||
/// used in core.ncl, mirroring `code/ontology/schemas/core.ncl::edge_type`; the
|
||||
/// prior enum listed four unused kinds and omitted four in active use.
|
||||
/// Edge type between nodes. Reconciled 2026-07-09 (G-2) to the vocabulary
|
||||
/// actually used in core.ncl, mirroring
|
||||
/// `code/ontology/schemas/core.ncl::edge_type`; the prior enum listed four
|
||||
/// unused kinds and omitted four in active use.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EdgeType {
|
||||
Complements,
|
||||
|
|
@ -275,7 +276,8 @@ pub enum ProofKind {
|
|||
Benchmark,
|
||||
}
|
||||
|
||||
/// Audience validation state (ADR-043) — Hypothesis until a Proof references it.
|
||||
/// Audience validation state (ADR-043) — Hypothesis until a Proof references
|
||||
/// it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AudienceStatus {
|
||||
#[default]
|
||||
|
|
@ -517,10 +519,11 @@ pub struct ViabilityConverges {
|
|||
|
||||
/// ViabilityPath — the sustainability seam (ADR-055), a second costura parallel
|
||||
/// to [`Proof`]. Where a Proof answers "is the claim real?", a ViabilityPath
|
||||
/// answers "does the claim sustain its maker?". It converges PARA QUIÉN × CÓMO ×
|
||||
/// (QUÉ×QUIÉN); nothing references it. `realized_outcome` is present only once a
|
||||
/// real measured compensation promotes the path past `Draft` (the one-directional
|
||||
/// realized⇒outcome gate, enforced by the Nickel `make_viability_path` contract).
|
||||
/// answers "does the claim sustain its maker?". It converges PARA QUIÉN × CÓMO
|
||||
/// × (QUÉ×QUIÉN); nothing references it. `realized_outcome` is present only
|
||||
/// once a real measured compensation promotes the path past `Draft` (the
|
||||
/// one-directional realized⇒outcome gate, enforced by the Nickel
|
||||
/// `make_viability_path` contract).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ViabilityPath {
|
||||
pub id: String,
|
||||
|
|
@ -599,7 +602,11 @@ mod positioning_tests {
|
|||
other => panic!("expected FileExists, got {other:?}"),
|
||||
}
|
||||
match &vp.launch_criteria[1] {
|
||||
LaunchCheck::Grep { pattern, paths, must_be_empty } => {
|
||||
LaunchCheck::Grep {
|
||||
pattern,
|
||||
paths,
|
||||
must_be_empty,
|
||||
} => {
|
||||
assert_eq!(pattern, "p");
|
||||
assert_eq!(paths, &vec!["a".to_string()]);
|
||||
assert!(!*must_be_empty);
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ use ontoref_ontology::types::StateConfig;
|
|||
|
||||
/// Project root that holds the `.ontoref/` spine.
|
||||
///
|
||||
/// Walks up from this crate until it finds the directory containing `.ontoref/`.
|
||||
/// Post-ADR-048 (constellation) the spine sits at the constellation parent,
|
||||
/// outside `code/`; pre-ADR-048 it was inside the workspace. Searching ancestors
|
||||
/// resolves it under either layout instead of hard-coding a hop count.
|
||||
/// Walks up from this crate until it finds the directory containing
|
||||
/// `.ontoref/`. Post-ADR-048 (constellation) the spine sits at the
|
||||
/// constellation parent, outside `code/`; pre-ADR-048 it was inside the
|
||||
/// workspace. Searching ancestors resolves it under either layout instead of
|
||||
/// hard-coding a hop count.
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
|
|
@ -46,6 +47,12 @@ fn repeated_render_produces_identical_bytes() {
|
|||
let second = render_state_ncl(&state);
|
||||
let third = render_state_ncl(&state);
|
||||
|
||||
assert_eq!(first, second, "render is not deterministic across two calls");
|
||||
assert_eq!(second, third, "render is not deterministic across three calls");
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"render is not deterministic across two calls"
|
||||
);
|
||||
assert_eq!(
|
||||
second, third,
|
||||
"render is not deterministic across three calls"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ use ontoref_ontology::types::StateConfig;
|
|||
|
||||
/// Project root that holds the `.ontoref/` spine.
|
||||
///
|
||||
/// Walks up from this crate until it finds the directory containing `.ontoref/`.
|
||||
/// Post-ADR-048 (constellation) the spine sits at the constellation parent,
|
||||
/// outside `code/`; pre-ADR-048 it was inside the workspace. Searching ancestors
|
||||
/// resolves it under either layout instead of hard-coding a hop count.
|
||||
/// Walks up from this crate until it finds the directory containing
|
||||
/// `.ontoref/`. Post-ADR-048 (constellation) the spine sits at the
|
||||
/// constellation parent, outside `code/`; pre-ADR-048 it was inside the
|
||||
/// workspace. Searching ancestors resolves it under either layout instead of
|
||||
/// hard-coding a hop count.
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
//! Typecheck pipeline for the `state.ncl` renderer (O1.5 / D29).
|
||||
//!
|
||||
//! Verifies two contracts:
|
||||
//! 1. Rendered bytes from any well-formed `StateConfig` pass
|
||||
//! `nickel typecheck`.
|
||||
//! 2. The transactional pipeline aborts and removes its staging file
|
||||
//! when typecheck fails — proving the invariant "every NCL file on
|
||||
//! disk parses against its schema".
|
||||
//! 1. Rendered bytes from any well-formed `StateConfig` pass `nickel
|
||||
//! typecheck`.
|
||||
//! 2. The transactional pipeline aborts and removes its staging file when
|
||||
//! typecheck fails — proving the invariant "every NCL file on disk parses
|
||||
//! against its schema".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
|
@ -16,10 +16,11 @@ use ontoref_ontology::types::StateConfig;
|
|||
|
||||
/// Project root that holds the `.ontoref/` spine.
|
||||
///
|
||||
/// Walks up from this crate until it finds the directory containing `.ontoref/`.
|
||||
/// Post-ADR-048 (constellation) the spine sits at the constellation parent,
|
||||
/// outside `code/`; pre-ADR-048 it was inside the workspace. Searching ancestors
|
||||
/// resolves it under either layout instead of hard-coding a hop count.
|
||||
/// Walks up from this crate until it finds the directory containing
|
||||
/// `.ontoref/`. Post-ADR-048 (constellation) the spine sits at the
|
||||
/// constellation parent, outside `code/`; pre-ADR-048 it was inside the
|
||||
/// workspace. Searching ancestors resolves it under either layout instead of
|
||||
/// hard-coding a hop count.
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
|
|
@ -89,5 +90,8 @@ fn pipeline_writes_atomically_when_typecheck_passes() {
|
|||
let staging_present = entries
|
||||
.iter()
|
||||
.any(|n| n.to_string_lossy().ends_with(".staged"));
|
||||
assert!(!staging_present, "staging file must be cleaned up on success");
|
||||
assert!(
|
||||
!staging_present,
|
||||
"staging file must be cleaned up on success"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ pub enum Error {
|
|||
/// An operation references a parent that is not in the log.
|
||||
#[error("unknown parent {0:?}")]
|
||||
UnknownParent(OpId),
|
||||
/// An operation declares a timestamp not strictly greater than every parent's.
|
||||
/// An operation declares a timestamp not strictly greater than every
|
||||
/// parent's.
|
||||
#[error("non-monotonic timestamp on operation {0:?}")]
|
||||
NonMonotonicTimestamp(OpId),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ pub struct OpLog {
|
|||
|
||||
impl OpLog {
|
||||
/// Open or create the log at `path`. The redb file is created on first use;
|
||||
/// tables are initialised inside a transaction so subsequent reads find them.
|
||||
/// tables are initialised inside a transaction so subsequent reads find
|
||||
/// them.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let db = Database::create(path).map_err(Error::from)?;
|
||||
// Touch both tables in a write transaction so they exist for reads.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_oplog::{Error, OpLog};
|
||||
use ontoref_types::{
|
||||
operation::{OpBody, OpPayload, Operation},
|
||||
Hlc, OpId, PublicKey,
|
||||
};
|
||||
|
||||
use ontoref_oplog::{Error, OpLog};
|
||||
|
||||
fn signing_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[42u8; 32])
|
||||
}
|
||||
|
|
@ -47,7 +46,11 @@ fn replay_yields_same_heads_for_linear_chain() {
|
|||
// Drop the OpLog (closes the redb handle), reopen the file, re-read heads.
|
||||
let reopened_heads = OpLog::open(&path).unwrap().heads().unwrap();
|
||||
assert_eq!(original_heads, reopened_heads);
|
||||
assert_eq!(original_heads.len(), 1, "a linear chain has exactly one head");
|
||||
assert_eq!(
|
||||
original_heads.len(),
|
||||
1,
|
||||
"a linear chain has exactly one head"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ static CAPABILITIES: &[TierCapability] = &[
|
|||
id: "describe",
|
||||
label: "Describe / self-knowledge",
|
||||
min_tier: Tier::Tier0,
|
||||
description: "describe project, capabilities, config, connections, state — full self-knowledge surface",
|
||||
description: "describe project, capabilities, config, connections, state — full \
|
||||
self-knowledge surface",
|
||||
},
|
||||
TierCapability {
|
||||
id: "adrs",
|
||||
|
|
@ -58,7 +59,8 @@ static CAPABILITIES: &[TierCapability] = &[
|
|||
id: "substrate",
|
||||
label: "Substrate (commit layer + state root)",
|
||||
min_tier: Tier::Tier1,
|
||||
description: "Content-addressed ontology commits with state root — enables signed attestations and audit trails (ADR-023)",
|
||||
description: "Content-addressed ontology commits with state root — enables signed \
|
||||
attestations and audit trails (ADR-023)",
|
||||
},
|
||||
TierCapability {
|
||||
id: "signed-artifacts",
|
||||
|
|
@ -70,25 +72,29 @@ static CAPABILITIES: &[TierCapability] = &[
|
|||
id: "ops-dispatch",
|
||||
label: "Ops dispatch (typed mutations)",
|
||||
min_tier: Tier::Tier2,
|
||||
description: "All state mutations route through declared domain operations — the agent action boundary (ADR-024)",
|
||||
description: "All state mutations route through declared domain operations — the agent \
|
||||
action boundary (ADR-024)",
|
||||
},
|
||||
TierCapability {
|
||||
id: "catalogued-ops",
|
||||
label: "Catalogued operations (ADR-026)",
|
||||
min_tier: Tier::Tier2,
|
||||
description: "Ops registered in the artifact catalog with validators, preconditions, and SLA declarations",
|
||||
description: "Ops registered in the artifact catalog with validators, preconditions, and \
|
||||
SLA declarations",
|
||||
},
|
||||
TierCapability {
|
||||
id: "runtime-mutation",
|
||||
label: "Runtime-only state mutation",
|
||||
min_tier: Tier::Tier2,
|
||||
description: "NCL files become render targets; all writes go through the ops runtime — human and AI paths unified",
|
||||
description: "NCL files become render targets; all writes go through the ops runtime — \
|
||||
human and AI paths unified",
|
||||
},
|
||||
TierCapability {
|
||||
id: "witness-verification",
|
||||
label: "Witness verification at boundary",
|
||||
min_tier: Tier::Tier2,
|
||||
description: "Every op produces a verifiable witness; validators reject ill-typed state without a valid witness chain",
|
||||
description: "Every op produces a verifiable witness; validators reject ill-typed state \
|
||||
without a valid witness chain",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -121,9 +127,8 @@ impl From<&'static TierCapability> for CapabilityRef {
|
|||
|
||||
/// Returns the capability split for the given tier.
|
||||
pub fn capability_view(current: Tier) -> CapabilityView {
|
||||
let (available, locked): (Vec<_>, Vec<_>) = CAPABILITIES
|
||||
.iter()
|
||||
.partition(|c| c.min_tier <= current);
|
||||
let (available, locked): (Vec<_>, Vec<_>) =
|
||||
CAPABILITIES.iter().partition(|c| c.min_tier <= current);
|
||||
CapabilityView {
|
||||
available: available.into_iter().map(CapabilityRef::from).collect(),
|
||||
locked: locked.into_iter().map(CapabilityRef::from).collect(),
|
||||
|
|
@ -160,9 +165,9 @@ pub fn tier_projection(current: Tier) -> TierProjection {
|
|||
.filter(|c| c.min_tier == Tier::Tier1)
|
||||
.map(CapabilityRef::from)
|
||||
.collect(),
|
||||
"Tier-1 adopts the substrate layer (ADR-023): content-addressed ontology commits, \
|
||||
a state root, and signed artifacts. Every mutation becomes auditable and \
|
||||
reproducible. Prerequisite for ops-dispatch at Tier-2.",
|
||||
"Tier-1 adopts the substrate layer (ADR-023): content-addressed ontology commits, a \
|
||||
state root, and signed artifacts. Every mutation becomes auditable and reproducible. \
|
||||
Prerequisite for ops-dispatch at Tier-2.",
|
||||
),
|
||||
Some(Tier::Tier2) => (
|
||||
CAPABILITIES
|
||||
|
|
@ -171,14 +176,22 @@ pub fn tier_projection(current: Tier) -> TierProjection {
|
|||
.map(CapabilityRef::from)
|
||||
.collect(),
|
||||
"Tier-2 adopts the operations layer (ADR-024): all state mutations route through \
|
||||
declared domain operations. NCL files become render targets. The runtime is the \
|
||||
only mutation path; witnesses are cryptographically verifiable; validators reject \
|
||||
declared domain operations. NCL files become render targets. The runtime is the only \
|
||||
mutation path; witnesses are cryptographically verifiable; validators reject \
|
||||
ill-typed state.",
|
||||
),
|
||||
None => (vec![], "Already at the highest tier. No further evolution is defined."),
|
||||
None => (
|
||||
vec![],
|
||||
"Already at the highest tier. No further evolution is defined.",
|
||||
),
|
||||
Some(_) => unreachable!(),
|
||||
};
|
||||
TierProjection { from: current, to, adds, narrative }
|
||||
TierProjection {
|
||||
from: current,
|
||||
to,
|
||||
adds,
|
||||
narrative,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
//! consults the guard, and the function returns one of two outcomes:
|
||||
//!
|
||||
//! - `ReloadAction::Apply(new)` — caller swaps registry/state in.
|
||||
//! - `ReloadAction::Block { .. }` — caller logs `Block`-severity
|
||||
//! notification and retains the previous config; the file on disk is
|
||||
//! NOT mutated (per ADR-033: the daemon only refuses to honour the
|
||||
//! edit, never overwrites the user's NCL).
|
||||
//! - `ReloadAction::Block { .. }` — caller logs `Block`-severity notification
|
||||
//! and retains the previous config; the file on disk is NOT mutated (per
|
||||
//! ADR-033: the daemon only refuses to honour the edit, never overwrites the
|
||||
//! user's NCL).
|
||||
|
||||
use crate::tier::OpsConfig;
|
||||
use crate::tier_guard::{enforce_tier_transition_guard, TierGuardError};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
extern crate self as ontoref_ops;
|
||||
|
||||
pub mod capabilities;
|
||||
pub mod config_reload;
|
||||
pub mod context;
|
||||
pub mod dispatch;
|
||||
pub mod error;
|
||||
|
|
@ -31,7 +32,6 @@ pub mod runtime;
|
|||
pub mod tier;
|
||||
pub mod tier_guard;
|
||||
pub mod tier_transition;
|
||||
pub mod config_reload;
|
||||
pub mod validation;
|
||||
pub mod witness;
|
||||
|
||||
|
|
@ -39,6 +39,6 @@ pub use context::OpContext;
|
|||
pub use dispatch::{dispatch_op, registered_op_ids};
|
||||
pub use error::OpError;
|
||||
pub use output::{EffectRecord, OpOutput};
|
||||
pub use registry::{OperationEntry, OpFn};
|
||||
pub use registry::{OpFn, OperationEntry};
|
||||
pub use runtime::{execute_op, PipelineMetrics, PipelineResult};
|
||||
pub use witness::{sign_witness, PipelineWitness};
|
||||
|
|
|
|||
|
|
@ -26,10 +26,7 @@ use crate::output::{EffectRecord, OpOutput};
|
|||
witness_shape = "dev_session_close",
|
||||
actor_policy = "developer,admin"
|
||||
)]
|
||||
fn close_dev_session(
|
||||
inputs: &serde_json::Value,
|
||||
ctx: &mut OpContext,
|
||||
) -> Result<OpOutput, OpError> {
|
||||
fn close_dev_session(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutput, OpError> {
|
||||
let session_id = inputs
|
||||
.get("session_id")
|
||||
.and_then(|v| v.as_str())
|
||||
|
|
@ -65,8 +62,8 @@ fn close_dev_session(
|
|||
);
|
||||
ctx.commit_layer.apply(&op);
|
||||
|
||||
let witness_payload = canonical::encode(&op)
|
||||
.map_err(|e| OpError::body("close_dev_session", e.to_string()))?;
|
||||
let witness_payload =
|
||||
canonical::encode(&op).map_err(|e| OpError::body("close_dev_session", e.to_string()))?;
|
||||
Ok(OpOutput {
|
||||
effects: vec![EffectRecord {
|
||||
kind: "Update".to_owned(),
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ use crate::context::OpContext;
|
|||
use crate::error::OpError;
|
||||
use crate::output::{EffectRecord, OpOutput};
|
||||
|
||||
/// Inputs: `{ topic: String, engaged_tensions: [String], synthesis_state: String, motion_direction: String }`.
|
||||
/// Inputs: `{ topic: String, engaged_tensions: [String], synthesis_state:
|
||||
/// String, motion_direction: String }`.
|
||||
#[onto_operation(
|
||||
id = "evaluate_ondaod",
|
||||
description = "Record an ondaod (Dao Discipline) evaluation as a meta-decision entity. No state mutation, no render.",
|
||||
description = "Record an ondaod (Dao Discipline) evaluation as a meta-decision entity. No \
|
||||
state mutation, no render.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "ondaod",
|
||||
pre_validators = "",
|
||||
|
|
@ -27,10 +29,7 @@ use crate::output::{EffectRecord, OpOutput};
|
|||
witness_shape = "ondaod_evaluation",
|
||||
actor_policy = "developer,admin,agent"
|
||||
)]
|
||||
fn evaluate_ondaod(
|
||||
inputs: &serde_json::Value,
|
||||
ctx: &mut OpContext,
|
||||
) -> Result<OpOutput, OpError> {
|
||||
fn evaluate_ondaod(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutput, OpError> {
|
||||
let topic = inputs
|
||||
.get("topic")
|
||||
.and_then(|v| v.as_str())
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ pub const WITNESS_SHAPE: &str = "backlog_item";
|
|||
/// - `status` (String, optional): `"Open" | "InProgress" | "Closed"`.
|
||||
#[onto_operation(
|
||||
id = "manage_backlog_item",
|
||||
description = "Add or update one backlog entry in reflection/backlog.ncl. Add asserts a fresh entry; update mutates an existing one in place.",
|
||||
description = "Add or update one backlog entry in reflection/backlog.ncl. Add asserts a fresh \
|
||||
entry; update mutates an existing one in place.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "backlog_open_items",
|
||||
pre_validators = "backlog_item_well_formed",
|
||||
|
|
@ -62,10 +63,7 @@ fn manage_backlog_item(
|
|||
.get_mut("items")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
.ok_or_else(|| {
|
||||
OpError::body(
|
||||
"manage_backlog_item",
|
||||
"ctx.backlog.items is not an array",
|
||||
)
|
||||
OpError::body("manage_backlog_item", "ctx.backlog.items is not an array")
|
||||
})?;
|
||||
|
||||
let id = inputs
|
||||
|
|
@ -152,7 +150,8 @@ fn manage_backlog_item(
|
|||
ctx.commit_layer.apply(&op);
|
||||
|
||||
if let Some(root) = ctx.project_root.as_ref() {
|
||||
let backlog_path = ontoref_ontology::layout::resolve_section(root, "reflection/backlog.ncl");
|
||||
let backlog_path =
|
||||
ontoref_ontology::layout::resolve_section(root, "reflection/backlog.ncl");
|
||||
if let Some(parent) = backlog_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| OpError::Render {
|
||||
path: parent.display().to_string(),
|
||||
|
|
@ -170,8 +169,8 @@ fn manage_backlog_item(
|
|||
})?;
|
||||
}
|
||||
|
||||
let witness_payload = canonical::encode(&op)
|
||||
.map_err(|e| OpError::body("manage_backlog_item", e.to_string()))?;
|
||||
let witness_payload =
|
||||
canonical::encode(&op).map_err(|e| OpError::body("manage_backlog_item", e.to_string()))?;
|
||||
|
||||
Ok(OpOutput {
|
||||
effects: vec![EffectRecord {
|
||||
|
|
@ -195,7 +194,8 @@ fn render_backlog_stub(backlog: &serde_json::Value) -> String {
|
|||
/// Reject malformed backlog requests at the validator boundary.
|
||||
#[onto_validator(
|
||||
id = "backlog_item_well_formed",
|
||||
description = "Reject manage_backlog_item when action is missing/invalid or required fields are absent.",
|
||||
description = "Reject manage_backlog_item when action is missing/invalid or required fields \
|
||||
are absent.",
|
||||
category = "Contextual",
|
||||
slice_query = "backlog_open_items"
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ pub const WITNESS_SHAPE: &str = "state_transition";
|
|||
/// can produce the corresponding `state.ncl` bytes.
|
||||
#[onto_operation(
|
||||
id = "move_fsm_state",
|
||||
description = "Transition an FSM dimension's current_state. Pre-validates the transition exists and post-validates FSM invariants.",
|
||||
description = "Transition an FSM dimension's current_state. Pre-validates the transition \
|
||||
exists and post-validates FSM invariants.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "fsm_dimension_by_id",
|
||||
pre_validators = "fsm_transition_allowed",
|
||||
|
|
@ -46,10 +47,7 @@ pub const WITNESS_SHAPE: &str = "state_transition";
|
|||
witness_shape = "state_transition",
|
||||
actor_policy = "developer,admin,ci"
|
||||
)]
|
||||
fn move_fsm_state(
|
||||
inputs: &serde_json::Value,
|
||||
ctx: &mut OpContext,
|
||||
) -> Result<OpOutput, OpError> {
|
||||
fn move_fsm_state(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutput, OpError> {
|
||||
let dim_id = inputs
|
||||
.get("dimension")
|
||||
.and_then(|v| v.as_str())
|
||||
|
|
@ -126,7 +124,8 @@ use crate::validation::verdict::Verdict;
|
|||
/// inputs: <op-inputs> }` (see `runtime::load_fsm_dimension_slice`).
|
||||
#[onto_validator(
|
||||
id = "fsm_transition_allowed",
|
||||
description = "Reject move_fsm_state when no transition with the requested `to` state exists in the dimension.",
|
||||
description = "Reject move_fsm_state when no transition with the requested `to` state exists \
|
||||
in the dimension.",
|
||||
category = "Contextual",
|
||||
slice_query = "fsm_dimension_by_id"
|
||||
)]
|
||||
|
|
@ -135,10 +134,7 @@ fn fsm_transition_allowed(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
|
|||
return Verdict::Unknown;
|
||||
};
|
||||
let Some(new_state) = inputs.get("new_state").and_then(|v| v.as_str()) else {
|
||||
return Verdict::reject(
|
||||
"missing 'new_state' input",
|
||||
"fsm_transition_allowed",
|
||||
);
|
||||
return Verdict::reject("missing 'new_state' input", "fsm_transition_allowed");
|
||||
};
|
||||
let Some(dimension) = slice.get("dimension") else {
|
||||
return Verdict::Unknown;
|
||||
|
|
@ -171,7 +167,8 @@ fn fsm_transition_allowed(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
|
|||
/// checks once those land.)
|
||||
#[onto_validator(
|
||||
id = "fsm_invariants_preserved",
|
||||
description = "Reject if the dimension's current_state does not appear in any transition after move_fsm_state has run.",
|
||||
description = "Reject if the dimension's current_state does not appear in any transition \
|
||||
after move_fsm_state has run.",
|
||||
category = "Contextual",
|
||||
slice_query = "fsm_dimension_by_id"
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -28,10 +28,12 @@ pub const WITNESS_SHAPE: &str = "adr_proposed";
|
|||
/// - `slug` (String): kebab-case slug for the file name.
|
||||
/// - `title` (String).
|
||||
/// - `decision` (String): the decision text.
|
||||
/// - `ondaod_evaluation` (Object): `{ engaged_tensions: [...], synthesis_state, motion_direction }`.
|
||||
/// - `ondaod_evaluation` (Object): `{ engaged_tensions: [...], synthesis_state,
|
||||
/// motion_direction }`.
|
||||
#[onto_operation(
|
||||
id = "propose_adr",
|
||||
description = "Declare a new ADR in Proposed status. Requires ondaod evaluation as precondition (ADR-024 / dao-discipline).",
|
||||
description = "Declare a new ADR in Proposed status. Requires ondaod evaluation as \
|
||||
precondition (ADR-024 / dao-discipline).",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "ondaod",
|
||||
pre_validators = "ondaod_engagement_declared",
|
||||
|
|
@ -146,7 +148,9 @@ fn render_adr_stub(id: &str, title: &str, decision: &str) -> String {
|
|||
let title_escaped = title.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let decision_escaped = decision.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!(
|
||||
"# Generated by `propose_adr` op (O4). Edit via `transition_adr` once accepted.\n\n{{\n id = \"{id}\",\n title = \"{title_escaped}\",\n status = 'Proposed,\n decision = \"{decision_escaped}\",\n}}\n"
|
||||
"# Generated by `propose_adr` op (O4). Edit via `transition_adr` once accepted.\n\n{{\n \
|
||||
id = \"{id}\",\n title = \"{title_escaped}\",\n status = 'Proposed,\n decision = \
|
||||
\"{decision_escaped}\",\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +159,8 @@ fn render_adr_stub(id: &str, title: &str, decision: &str) -> String {
|
|||
/// other. Slice shape: see `runtime::load_ondaod_slice`.
|
||||
#[onto_validator(
|
||||
id = "ondaod_engagement_declared",
|
||||
description = "Reject propose_adr when the ondaod evaluation declares neither engaged tensions nor an empty-set rationale.",
|
||||
description = "Reject propose_adr when the ondaod evaluation declares neither engaged \
|
||||
tensions nor an empty-set rationale.",
|
||||
category = "Contextual",
|
||||
slice_query = "ondaod"
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ use crate::output::{EffectRecord, OpOutput};
|
|||
|
||||
#[onto_operation(
|
||||
id = "start_dev_session",
|
||||
description = "Open a code dev session with declared scope and target paths. Records session id, scope, and applicable ADRs.",
|
||||
description = "Open a code dev session with declared scope and target paths. Records session \
|
||||
id, scope, and applicable ADRs.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "",
|
||||
pre_validators = "",
|
||||
|
|
@ -25,10 +26,7 @@ use crate::output::{EffectRecord, OpOutput};
|
|||
witness_shape = "dev_session_start",
|
||||
actor_policy = "developer,admin"
|
||||
)]
|
||||
fn start_dev_session(
|
||||
inputs: &serde_json::Value,
|
||||
ctx: &mut OpContext,
|
||||
) -> Result<OpOutput, OpError> {
|
||||
fn start_dev_session(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutput, OpError> {
|
||||
let scope = inputs
|
||||
.get("scope")
|
||||
.and_then(|v| v.as_str())
|
||||
|
|
@ -66,8 +64,8 @@ fn start_dev_session(
|
|||
);
|
||||
ctx.commit_layer.apply(&op);
|
||||
|
||||
let witness_payload = canonical::encode(&op)
|
||||
.map_err(|e| OpError::body("start_dev_session", e.to_string()))?;
|
||||
let witness_payload =
|
||||
canonical::encode(&op).map_err(|e| OpError::body("start_dev_session", e.to_string()))?;
|
||||
Ok(OpOutput {
|
||||
effects: vec![EffectRecord {
|
||||
kind: "Insert".to_owned(),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ use crate::output::{EffectRecord, OpOutput};
|
|||
|
||||
#[onto_operation(
|
||||
id = "sync_apply",
|
||||
description = "Apply a batch of reconciled diffs as a single sync event. Phase 1 records the batch attempt; recursive dispatch arrives later.",
|
||||
description = "Apply a batch of reconciled diffs as a single sync event. Phase 1 records the \
|
||||
batch attempt; recursive dispatch arrives later.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "",
|
||||
pre_validators = "",
|
||||
|
|
@ -38,10 +39,7 @@ fn sync_apply(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutpu
|
|||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let entity_id = format!(
|
||||
"sync_batch:{}",
|
||||
blake3::hash(summary.as_bytes()).to_hex()
|
||||
);
|
||||
let entity_id = format!("sync_batch:{}", blake3::hash(summary.as_bytes()).to_hex());
|
||||
let signing_key = ctx.signing_key.clone();
|
||||
let actor = ontoref_types::PublicKey::from_signing_key(&signing_key);
|
||||
let timestamp = ctx.next_hlc();
|
||||
|
|
@ -51,14 +49,8 @@ fn sync_apply(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutpu
|
|||
parents: vec![],
|
||||
payload: OpPayload::EntityAssert {
|
||||
attrs: vec![
|
||||
(
|
||||
AttrId::new("diff_count"),
|
||||
Value::Int(diff_count as i64),
|
||||
),
|
||||
(
|
||||
AttrId::new("summary"),
|
||||
Value::Str(SmolStr::new(&summary)),
|
||||
),
|
||||
(AttrId::new("diff_count"), Value::Int(diff_count as i64)),
|
||||
(AttrId::new("summary"), Value::Str(SmolStr::new(&summary))),
|
||||
],
|
||||
entity: EntityId::new(&entity_id),
|
||||
valid_from: None,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ use crate::validation::verdict::Verdict;
|
|||
|
||||
#[onto_operation(
|
||||
id = "transition_adr",
|
||||
description = "Move an ADR from Proposed to Accepted/Deprecated, or from Accepted to Superseded. Accept requires ondaod.",
|
||||
description = "Move an ADR from Proposed to Accepted/Deprecated, or from Accepted to \
|
||||
Superseded. Accept requires ondaod.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "ondaod",
|
||||
pre_validators = "adr_transition_allowed",
|
||||
|
|
@ -79,7 +80,8 @@ fn transition_adr(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpO
|
|||
/// Accept transitions without ondaod engagement.
|
||||
#[onto_validator(
|
||||
id = "adr_transition_allowed",
|
||||
description = "Reject unsupported ADR transitions; require ondaod when transitioning to Accepted.",
|
||||
description = "Reject unsupported ADR transitions; require ondaod when transitioning to \
|
||||
Accepted.",
|
||||
category = "Contextual",
|
||||
slice_query = "ondaod"
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ fn current_ops_from_disk(project_root: &Path) -> OpsConfig {
|
|||
|
||||
#[onto_operation(
|
||||
id = "transition_tier",
|
||||
description = "Mutate the project's `ops.tier` declaration. Enforces ADR-029 / ADR-033 guards: refuses when migrations are pending, atomically rewrites .ontoref/config.ncl, emits a tier-transition witness covering the change.",
|
||||
description = "Mutate the project's `ops.tier` declaration. Enforces ADR-029 / ADR-033 \
|
||||
guards: refuses when migrations are pending, atomically rewrites \
|
||||
.ontoref/config.ncl, emits a tier-transition witness covering the change.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "",
|
||||
pre_validators = "",
|
||||
|
|
@ -76,10 +78,7 @@ fn current_ops_from_disk(project_root: &Path) -> OpsConfig {
|
|||
witness_shape = "tier_transition",
|
||||
actor_policy = "developer,admin"
|
||||
)]
|
||||
fn transition_tier(
|
||||
inputs: &serde_json::Value,
|
||||
ctx: &mut OpContext,
|
||||
) -> Result<OpOutput, OpError> {
|
||||
fn transition_tier(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutput, OpError> {
|
||||
let target_str = inputs
|
||||
.get("target_tier")
|
||||
.and_then(|v| v.as_str())
|
||||
|
|
@ -106,9 +105,8 @@ fn transition_tier(
|
|||
descent_signing_key: Some(ctx.signing_key.clone()),
|
||||
};
|
||||
|
||||
let outcome = execute(target, transition_ctx).map_err(|e| {
|
||||
OpError::body("transition_tier", format!("tier transition failed: {e}"))
|
||||
})?;
|
||||
let outcome = execute(target, transition_ctx)
|
||||
.map_err(|e| OpError::body("transition_tier", format!("tier transition failed: {e}")))?;
|
||||
|
||||
let entity_id = "project_ops:state".to_owned();
|
||||
let signing_key = ctx.signing_key.clone();
|
||||
|
|
@ -142,8 +140,8 @@ fn transition_tier(
|
|||
);
|
||||
ctx.commit_layer.apply(&op);
|
||||
|
||||
let witness_payload = canonical::encode(&op)
|
||||
.map_err(|e| OpError::body("transition_tier", e.to_string()))?;
|
||||
let witness_payload =
|
||||
canonical::encode(&op).map_err(|e| OpError::body("transition_tier", e.to_string()))?;
|
||||
|
||||
Ok(OpOutput {
|
||||
effects: vec![EffectRecord {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ use crate::validation::verdict::Verdict;
|
|||
|
||||
#[onto_operation(
|
||||
id = "update_ontology_edge",
|
||||
description = "Insert one edge into core.ncl edges array. Pre-validator rejects direct self-loops and 1-step cycles.",
|
||||
description = "Insert one edge into core.ncl edges array. Pre-validator rejects direct \
|
||||
self-loops and 1-step cycles.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "ontology_edge_dag_check",
|
||||
pre_validators = "ontology_edge_no_cycle",
|
||||
|
|
@ -105,10 +106,7 @@ fn ontology_edge_no_cycle(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
|
|||
);
|
||||
}
|
||||
if from == to {
|
||||
return Verdict::reject(
|
||||
"self-loop rejected (from == to)",
|
||||
"ontology_edge_no_cycle",
|
||||
);
|
||||
return Verdict::reject("self-loop rejected (from == to)", "ontology_edge_no_cycle");
|
||||
}
|
||||
let reverse_exists = slice
|
||||
.get("existing_reverse")
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ pub const WITNESS_SHAPE: &str = "node_upsert";
|
|||
/// - `invariant` (Bool, optional).
|
||||
#[onto_operation(
|
||||
id = "update_ontology_node",
|
||||
description = "Insert or update one node in .ontology/core.ncl. Mutates ctx.core in place and writes the file back via Phase 1 inline render.",
|
||||
description = "Insert or update one node in .ontology/core.ncl. Mutates ctx.core in place and \
|
||||
writes the file back via Phase 1 inline render.",
|
||||
validation_sla = "Synchronous",
|
||||
precondition = "ontology_node_by_id",
|
||||
pre_validators = "ontology_node_well_formed",
|
||||
|
|
@ -150,10 +151,14 @@ fn update_ontology_node(
|
|||
})?;
|
||||
}
|
||||
|
||||
let witness_payload = canonical::encode(&op)
|
||||
.map_err(|e| OpError::body("update_ontology_node", e.to_string()))?;
|
||||
let witness_payload =
|
||||
canonical::encode(&op).map_err(|e| OpError::body("update_ontology_node", e.to_string()))?;
|
||||
|
||||
let effect_kind = if existing_idx.is_some() { "Update" } else { "Insert" };
|
||||
let effect_kind = if existing_idx.is_some() {
|
||||
"Update"
|
||||
} else {
|
||||
"Insert"
|
||||
};
|
||||
Ok(OpOutput {
|
||||
effects: vec![EffectRecord {
|
||||
kind: effect_kind.to_owned(),
|
||||
|
|
@ -180,7 +185,8 @@ fn render_core_stub(core: &serde_json::Value) -> String {
|
|||
/// Reject when the proposed node is missing required identity fields.
|
||||
#[onto_validator(
|
||||
id = "ontology_node_well_formed",
|
||||
description = "Reject update_ontology_node when the proposed node lacks id, or when both name and description are missing on an insert.",
|
||||
description = "Reject update_ontology_node when the proposed node lacks id, or when both name \
|
||||
and description are missing on an insert.",
|
||||
category = "Contextual",
|
||||
slice_query = "ontology_node_by_id"
|
||||
)]
|
||||
|
|
@ -190,10 +196,7 @@ fn ontology_node_well_formed(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
|
|||
};
|
||||
let node_id = inputs.get("node_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if node_id.is_empty() {
|
||||
return Verdict::reject(
|
||||
"missing 'node_id' input",
|
||||
"ontology_node_well_formed",
|
||||
);
|
||||
return Verdict::reject("missing 'node_id' input", "ontology_node_well_formed");
|
||||
}
|
||||
let is_insert = slice
|
||||
.get("existing_node")
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// NOTE: this loader intentionally does NOT use `ontoref_ontology::Core::from_value`
|
||||
// because that constructor parses the `edges` array, and the existing
|
||||
// `Edge.weight: f64` Rust type does not match the real `.ontology/core.ncl`
|
||||
// edges (which carry `weight = 'High'` enum tags). Fixing the Edge weight type
|
||||
// is tracked separately; O0 reads only the `nodes` array which is unaffected.
|
||||
// NOTE: this loader intentionally does NOT use
|
||||
// `ontoref_ontology::Core::from_value` because that constructor parses the
|
||||
// `edges` array, and the existing `Edge.weight: f64` Rust type does not match
|
||||
// the real `.ontology/core.ncl` edges (which carry `weight = 'High'` enum
|
||||
// tags). Fixing the Edge weight type is tracked separately; O0 reads only the
|
||||
// `nodes` array which is unaffected.
|
||||
|
||||
/// Canonical Q&A entry id for the protocol baseline ondaod procedure.
|
||||
pub const ONDAOD_QA_ID: &str = "ontoref-dao-discipline";
|
||||
|
|
@ -157,8 +158,8 @@ pub fn load(
|
|||
})
|
||||
.collect();
|
||||
|
||||
let qa: QaStore = serde_json::from_value(qa_json.clone())
|
||||
.map_err(|e| OndaodError::Qa(anyhow::anyhow!(e)))?;
|
||||
let qa: QaStore =
|
||||
serde_json::from_value(qa_json.clone()).map_err(|e| OndaodError::Qa(anyhow::anyhow!(e)))?;
|
||||
|
||||
let entry = qa
|
||||
.entries
|
||||
|
|
@ -182,10 +183,10 @@ pub fn load(
|
|||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`OndaodError::UnknownTension`] when any `tension_id` in `engaged` is
|
||||
/// not present in `context.tensions`. The evaluation rejects entirely;
|
||||
/// partial application is not supported because the discipline requires
|
||||
/// the engagement set to be coherent with the loaded tensions snapshot.
|
||||
/// - [`OndaodError::UnknownTension`] when any `tension_id` in `engaged` is not
|
||||
/// present in `context.tensions`. The evaluation rejects entirely; partial
|
||||
/// application is not supported because the discipline requires the
|
||||
/// engagement set to be coherent with the loaded tensions snapshot.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
|
|
@ -263,10 +264,11 @@ struct NodeRow {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
// CARGO_MANIFEST_DIR = crates/ontoref-ops; parent twice = workspace root.
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
|
|
@ -296,8 +298,14 @@ mod tests {
|
|||
#[test]
|
||||
fn load_discipline_and_evaluate() {
|
||||
let root = workspace_root();
|
||||
let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(&root, "ontology/core.ncl"));
|
||||
let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(&root, "reflection/qa.ncl"));
|
||||
let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(
|
||||
&root,
|
||||
"ontology/core.ncl",
|
||||
));
|
||||
let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(
|
||||
&root,
|
||||
"reflection/qa.ncl",
|
||||
));
|
||||
|
||||
let context = load(&core_json, &qa_json).expect("load ondaod context");
|
||||
|
||||
|
|
@ -339,8 +347,14 @@ mod tests {
|
|||
#[test]
|
||||
fn empty_engagement_is_explicit() {
|
||||
let root = workspace_root();
|
||||
let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(&root, "ontology/core.ncl"));
|
||||
let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(&root, "reflection/qa.ncl"));
|
||||
let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(
|
||||
&root,
|
||||
"ontology/core.ncl",
|
||||
));
|
||||
let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(
|
||||
&root,
|
||||
"reflection/qa.ncl",
|
||||
));
|
||||
|
||||
let context = load(&core_json, &qa_json).expect("load ondaod context");
|
||||
let evaluation = evaluate(context, vec![], MotionDirection::Static)
|
||||
|
|
@ -353,8 +367,14 @@ mod tests {
|
|||
#[test]
|
||||
fn unknown_tension_rejected() {
|
||||
let root = workspace_root();
|
||||
let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(&root, "ontology/core.ncl"));
|
||||
let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(&root, "reflection/qa.ncl"));
|
||||
let core_json = nickel_export(&ontoref_ontology::layout::resolve_section(
|
||||
&root,
|
||||
"ontology/core.ncl",
|
||||
));
|
||||
let qa_json = nickel_export(&ontoref_ontology::layout::resolve_section(
|
||||
&root,
|
||||
"reflection/qa.ncl",
|
||||
));
|
||||
|
||||
let context = load(&core_json, &qa_json).expect("load ondaod context");
|
||||
let err = evaluate(
|
||||
|
|
|
|||
|
|
@ -62,7 +62,10 @@ pub fn diff_state(old: &StateConfig, new: &StateConfig) -> Vec<StateDiff> {
|
|||
diffs.push(StateDiff {
|
||||
op_id: String::new(),
|
||||
inputs: serde_json::json!({ "dimension_added": new_dim.id }),
|
||||
summary: format!("dimension '{}' added — no catalogued op covers this", new_dim.id),
|
||||
summary: format!(
|
||||
"dimension '{}' added — no catalogued op covers this",
|
||||
new_dim.id
|
||||
),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
|
@ -88,7 +91,8 @@ pub fn diff_state(old: &StateConfig, new: &StateConfig) -> Vec<StateDiff> {
|
|||
"new_desired": new_dim.desired_state,
|
||||
}),
|
||||
summary: format!(
|
||||
"dimension '{}' desired_state: '{}' → '{}' — no catalogued op covers this in Phase 1",
|
||||
"dimension '{}' desired_state: '{}' → '{}' — no catalogued op covers this in \
|
||||
Phase 1",
|
||||
new_dim.id, old_dim.desired_state, new_dim.desired_state
|
||||
),
|
||||
});
|
||||
|
|
@ -117,10 +121,10 @@ pub fn diff_state(old: &StateConfig, new: &StateConfig) -> Vec<StateDiff> {
|
|||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`ReconcileError::UncatalogedDiff`] when any diff carries an empty
|
||||
/// `op_id` (no registered op covers the edit).
|
||||
/// - [`ReconcileError::Dispatch`] for runtime errors propagated from
|
||||
/// the underlying [`dispatch_op`].
|
||||
/// - [`ReconcileError::UncatalogedDiff`] when any diff carries an empty `op_id`
|
||||
/// (no registered op covers the edit).
|
||||
/// - [`ReconcileError::Dispatch`] for runtime errors propagated from the
|
||||
/// underlying [`dispatch_op`].
|
||||
pub fn apply_diffs(
|
||||
diffs: &[StateDiff],
|
||||
ctx: &mut OpContext,
|
||||
|
|
@ -156,11 +160,12 @@ pub fn apply_diffs(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ontoref_ontology::types::{
|
||||
Dimension, DimensionState, Horizon, StateConfig, TensionLevel, Transition,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dim(id: &str, current: &str) -> Dimension {
|
||||
Dimension {
|
||||
id: id.to_owned(),
|
||||
|
|
@ -247,8 +252,12 @@ mod tests {
|
|||
a.desired_state = "z".to_owned();
|
||||
let mut b = dim("d1", "a");
|
||||
b.desired_state = "q".to_owned();
|
||||
let s1 = StateConfig { dimensions: vec![a] };
|
||||
let s2 = StateConfig { dimensions: vec![b] };
|
||||
let s1 = StateConfig {
|
||||
dimensions: vec![a],
|
||||
};
|
||||
let s2 = StateConfig {
|
||||
dimensions: vec![b],
|
||||
};
|
||||
let diffs = diff_state(&s1, &s2);
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert!(diffs[0].op_id.is_empty());
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Pipeline executor — `load → pre_validate → exec → apply → render → post_validate → emit_witness`.
|
||||
//! Pipeline executor — `load → pre_validate → exec → apply → render →
|
||||
//! post_validate → emit_witness`.
|
||||
//!
|
||||
//! Phase 1 wires the skeleton: every step is instrumented with
|
||||
//! microsecond-resolution metrics (ADR-026 SLA observability). Concrete
|
||||
|
|
@ -74,9 +75,9 @@ pub fn execute_op(
|
|||
// reference it without an extra parameter.
|
||||
ctx.op_id = entry.id.to_owned();
|
||||
|
||||
// 1. Precondition load — dispatched by entry.precondition tag.
|
||||
// Bundles the loaded slice with the op's inputs so validators
|
||||
// see both without an extra parameter.
|
||||
// 1. Precondition load — dispatched by entry.precondition tag. Bundles the
|
||||
// loaded slice with the op's inputs so validators see both without an extra
|
||||
// parameter.
|
||||
let t0 = Instant::now();
|
||||
let slice = load_precondition(entry, inputs, ctx)?;
|
||||
metrics.precondition_load_us = elapsed_us(t0);
|
||||
|
|
@ -98,8 +99,8 @@ pub fn execute_op(
|
|||
apply_effects(entry, &output, ctx);
|
||||
metrics.apply_us = elapsed_us(t0);
|
||||
|
||||
// 5. Render NCL paths — Phase 1 supports state.ncl when
|
||||
// ctx.state + ctx.project_root are set.
|
||||
// 5. Render NCL paths — Phase 1 supports state.ncl when ctx.state +
|
||||
// ctx.project_root are set.
|
||||
let t0 = Instant::now();
|
||||
render_paths(entry, ctx)?;
|
||||
metrics.render_us = elapsed_us(t0);
|
||||
|
|
@ -107,7 +108,12 @@ pub fn execute_op(
|
|||
// 6. Post-validators — re-load the slice (state has mutated) and run.
|
||||
let t0 = Instant::now();
|
||||
let post_slice = load_precondition(entry, inputs, ctx)?;
|
||||
run_validators(entry.id, entry.post_validators, &post_slice, &validation_ctx)?;
|
||||
run_validators(
|
||||
entry.id,
|
||||
entry.post_validators,
|
||||
&post_slice,
|
||||
&validation_ctx,
|
||||
)?;
|
||||
metrics.post_validate_us = elapsed_us(t0);
|
||||
|
||||
// 7. Emit witness.
|
||||
|
|
@ -122,12 +128,11 @@ pub fn execute_op(
|
|||
);
|
||||
metrics.witness_us = elapsed_us(t0);
|
||||
|
||||
// 8. Persist the witness to the oplog as the final stage (substrate
|
||||
// feature). Append-first semantics: the oplog is the source of
|
||||
// truth, the rendered NCL is a projection. Done here, after the
|
||||
// in-memory render, the only divergence window is render-ok /
|
||||
// append-fail, surfaced as OpError::WitnessPersist for the caller
|
||||
// to reconcile.
|
||||
// 8. Persist the witness to the oplog as the final stage (substrate feature).
|
||||
// Append-first semantics: the oplog is the source of truth, the rendered NCL
|
||||
// is a projection. Done here, after the in-memory render, the only
|
||||
// divergence window is render-ok / append-fail, surfaced as
|
||||
// OpError::WitnessPersist for the caller to reconcile.
|
||||
#[cfg(feature = "substrate")]
|
||||
let oplog_op_id = persist_witness(&witness, ctx)?;
|
||||
|
||||
|
|
@ -256,9 +261,10 @@ fn load_ondaod_slice(
|
|||
// crate::precondition::ondaod::load arrives when the runtime takes
|
||||
// ownership of the qa.ncl + core.ncl JSON snapshots.
|
||||
let mut slice = serde_json::Map::new();
|
||||
let ondaod = inputs.get("ondaod_evaluation").cloned().unwrap_or_else(|| {
|
||||
serde_json::json!({ "engaged_tensions": [], "synthesis_state": "" })
|
||||
});
|
||||
let ondaod = inputs
|
||||
.get("ondaod_evaluation")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({ "engaged_tensions": [], "synthesis_state": "" }));
|
||||
slice.insert("ondaod".to_owned(), ondaod);
|
||||
slice.insert("inputs".to_owned(), inputs.clone());
|
||||
slice.insert(
|
||||
|
|
@ -318,20 +324,22 @@ fn load_ontology_edge_dag_check_slice(
|
|||
inputs: &serde_json::Value,
|
||||
ctx: &OpContext,
|
||||
) -> Result<serde_json::Value, OpError> {
|
||||
let from = inputs
|
||||
.get("from")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OpError::PreconditionLoad {
|
||||
op_id: entry.id.to_owned(),
|
||||
reason: "missing 'from' input".to_owned(),
|
||||
})?;
|
||||
let to = inputs
|
||||
.get("to")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OpError::PreconditionLoad {
|
||||
op_id: entry.id.to_owned(),
|
||||
reason: "missing 'to' input".to_owned(),
|
||||
})?;
|
||||
let from =
|
||||
inputs
|
||||
.get("from")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OpError::PreconditionLoad {
|
||||
op_id: entry.id.to_owned(),
|
||||
reason: "missing 'from' input".to_owned(),
|
||||
})?;
|
||||
let to =
|
||||
inputs
|
||||
.get("to")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OpError::PreconditionLoad {
|
||||
op_id: entry.id.to_owned(),
|
||||
reason: "missing 'to' input".to_owned(),
|
||||
})?;
|
||||
// The reverse edge (to → from) is what would close a 1-step cycle.
|
||||
let existing_reverse = ctx
|
||||
.core
|
||||
|
|
@ -370,7 +378,8 @@ fn load_fsm_dimension_slice(
|
|||
.as_ref()
|
||||
.ok_or_else(|| OpError::PreconditionLoad {
|
||||
op_id: entry.id.to_owned(),
|
||||
reason: "ctx.state is None — fsm_dimension_by_id requires a loaded StateConfig".to_owned(),
|
||||
reason: "ctx.state is None — fsm_dimension_by_id requires a loaded StateConfig"
|
||||
.to_owned(),
|
||||
})?;
|
||||
let dimension = state
|
||||
.dimensions
|
||||
|
|
@ -483,7 +492,8 @@ fn pipeline() {
|
|||
// paths. Exercises every pipeline step in skeleton form.
|
||||
#[onto_operation(
|
||||
id = "noop_pipeline_smoke",
|
||||
description = "Noop op used by the O2 runtime smoke test — no precondition, no validators, no effects, no render paths.",
|
||||
description = "Noop op used by the O2 runtime smoke test — no precondition, no \
|
||||
validators, no effects, no render paths.",
|
||||
validation_sla = "Synchronous",
|
||||
witness_shape = "noop"
|
||||
)]
|
||||
|
|
@ -543,7 +553,8 @@ fn pipeline_rejects_disallowed_actor() {
|
|||
/// Restricted op for actor-policy negative coverage.
|
||||
#[onto_operation(
|
||||
id = "restricted_op_smoke",
|
||||
description = "Restricted to the admin role — used by O2 to verify actor policy enforcement.",
|
||||
description = "Restricted to the admin role — used by O2 to verify actor policy \
|
||||
enforcement.",
|
||||
validation_sla = "Synchronous",
|
||||
actor_policy = "admin",
|
||||
witness_shape = "noop"
|
||||
|
|
@ -579,7 +590,8 @@ fn witness_persists_to_oplog_and_verifies_in_fresh_process() {
|
|||
/// Noop op exercising the full pipeline including witness persistence.
|
||||
#[onto_operation(
|
||||
id = "substrate_persist_smoke",
|
||||
description = "Noop op used by the substrate witness-persistence test — dispatches, signs, and appends to the oplog.",
|
||||
description = "Noop op used by the substrate witness-persistence test — dispatches, \
|
||||
signs, and appends to the oplog.",
|
||||
validation_sla = "Synchronous",
|
||||
witness_shape = "noop"
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ pub enum TierGuardError {
|
|||
/// Callers MUST refuse to apply the new config until the migration
|
||||
/// baseline is clean.
|
||||
#[error(
|
||||
"tier transition {old_tier:?} -> {new_tier:?} blocked: {pending} migration(s) pending: {ids:?}"
|
||||
"tier transition {old_tier:?} -> {new_tier:?} blocked: {pending} migration(s) pending: \
|
||||
{ids:?}"
|
||||
)]
|
||||
MigrationsPending {
|
||||
/// Tier value declared by the OLD config (or default if absent).
|
||||
|
|
@ -54,8 +55,8 @@ pub enum TierGuardError {
|
|||
/// Note: the function does NOT check `ops.phase` — Phase transitions
|
||||
/// (`Progressive` → `Pure` within tier-2) are governed by a separate
|
||||
/// constraint (future ADR-033). This guard is scoped to tier transitions
|
||||
/// only, matching the ADR-029 / `tier-transition-requires-clean-migration-state`
|
||||
/// constraint precisely.
|
||||
/// only, matching the ADR-029 /
|
||||
/// `tier-transition-requires-clean-migration-state` constraint precisely.
|
||||
pub fn enforce_tier_transition_guard(
|
||||
old: &OpsConfig,
|
||||
new: &OpsConfig,
|
||||
|
|
|
|||
|
|
@ -23,17 +23,17 @@
|
|||
//! - `transition-tier-uses-existing-guard` — [`execute`] invokes
|
||||
//! [`crate::tier_guard::enforce_tier_transition_guard`] as its first
|
||||
//! validator before any effect runs.
|
||||
//! - `transition-tier-single-rust-function` — this is the single entry
|
||||
//! that both the CLI surface and the future catalogued op must call.
|
||||
//! - `transition-tier-single-rust-function` — this is the single entry that
|
||||
//! both the CLI surface and the future catalogued op must call.
|
||||
//! - `transition-tier-transactional-or-noop` — file replace happens via
|
||||
//! tempfile + atomic rename. On any pre-rewrite failure the original
|
||||
//! `.ontoref/config.ncl` is untouched. The atomicity tests in this
|
||||
//! module pin the property.
|
||||
//! `.ontoref/config.ncl` is untouched. The atomicity tests in this module pin
|
||||
//! the property.
|
||||
//!
|
||||
//! Hard constraints DEFERRED to substrate integration:
|
||||
//!
|
||||
//! - `downgrade-preserves-substrate-immutable` — no substrate to preserve
|
||||
//! yet in this module. Will be verified when oplog/keys effects land.
|
||||
//! - `downgrade-preserves-substrate-immutable` — no substrate to preserve yet
|
||||
//! in this module. Will be verified when oplog/keys effects land.
|
||||
//! - `tier-2-descent-emits-final-witness` — witness emission requires the
|
||||
//! keypair surface and the oplog writer; deferred.
|
||||
|
||||
|
|
@ -42,10 +42,9 @@ use std::io::Write;
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[cfg(feature = "substrate")]
|
||||
use ed25519_dalek::SigningKey;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::tier::{OpsConfig, Phase, Tier};
|
||||
use crate::tier_guard::{enforce_tier_transition_guard, TierGuardError};
|
||||
|
|
@ -155,14 +154,15 @@ fn rewrite_config_tier(config_path: &Path, target: Tier) -> Result<(), Transitio
|
|||
})?;
|
||||
|
||||
let target_atom = format!("'{target:?}");
|
||||
let rewritten =
|
||||
replace_tier_value(&original, &target_atom).ok_or_else(|| TransitionError::OpsBlockAbsent {
|
||||
let rewritten = replace_tier_value(&original, &target_atom).ok_or_else(|| {
|
||||
TransitionError::OpsBlockAbsent {
|
||||
path: config_path.to_path_buf(),
|
||||
})?;
|
||||
|
||||
let parent = config_path.parent().ok_or_else(|| {
|
||||
TransitionError::Io(std::io::Error::other("config path has no parent"))
|
||||
}
|
||||
})?;
|
||||
|
||||
let parent = config_path
|
||||
.parent()
|
||||
.ok_or_else(|| TransitionError::Io(std::io::Error::other("config path has no parent")))?;
|
||||
let mut tmp = NamedTempFile::new_in(parent)?;
|
||||
tmp.write_all(rewritten.as_bytes())?;
|
||||
tmp.flush()?;
|
||||
|
|
@ -252,20 +252,19 @@ fn now_unix_ms() -> u128 {
|
|||
/// keys, no secret values). Only the protocol-level fields named by the
|
||||
/// constraint: requested target tier, original tier, failure source,
|
||||
/// timestamp.
|
||||
fn log_failed_transition(
|
||||
project_root: &Path,
|
||||
requested: Tier,
|
||||
original: Tier,
|
||||
source: &str,
|
||||
) {
|
||||
let log_dir = project_root.join(".ontoref").join("artifacts").join("transition-log");
|
||||
fn log_failed_transition(project_root: &Path, requested: Tier, original: Tier, source: &str) {
|
||||
let log_dir = project_root
|
||||
.join(".ontoref")
|
||||
.join("artifacts")
|
||||
.join("transition-log");
|
||||
if fs::create_dir_all(&log_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let ts = now_unix_ms();
|
||||
let log_path = log_dir.join(format!("{ts}-failed.ncl"));
|
||||
let body = format!(
|
||||
"{{\n timestamp = {ts},\n requested_tier = \"{requested:?}\",\n original_tier = \"{original:?}\",\n source = \"{source}\",\n}}\n"
|
||||
"{{\n timestamp = {ts},\n requested_tier = \"{requested:?}\",\n original_tier = \
|
||||
\"{original:?}\",\n source = \"{source}\",\n}}\n"
|
||||
);
|
||||
let _ = fs::write(log_path, body);
|
||||
}
|
||||
|
|
@ -280,7 +279,8 @@ enum Compensation {
|
|||
/// Remove an oplog file this transition created, and its parent
|
||||
/// `substrate/` directory when this transition created that too. Only
|
||||
/// scheduled for substrate we created — pre-existing substrate is
|
||||
/// immutable on rollback (ADR-033 `downgrade-preserves-substrate-immutable`).
|
||||
/// immutable on rollback (ADR-033
|
||||
/// `downgrade-preserves-substrate-immutable`).
|
||||
RemoveOplog {
|
||||
/// Path of the oplog file to remove.
|
||||
oplog_path: PathBuf,
|
||||
|
|
@ -488,11 +488,9 @@ pub fn execute(
|
|||
tier: target_tier,
|
||||
phase: ctx.current_ops.phase,
|
||||
};
|
||||
if let Err(e) = enforce_tier_transition_guard(
|
||||
&ctx.current_ops,
|
||||
&target_ops,
|
||||
&ctx.pending_migrations,
|
||||
) {
|
||||
if let Err(e) =
|
||||
enforce_tier_transition_guard(&ctx.current_ops, &target_ops, &ctx.pending_migrations)
|
||||
{
|
||||
log_failed_transition(
|
||||
&ctx.project_root,
|
||||
target_tier,
|
||||
|
|
@ -539,7 +537,9 @@ pub fn execute(
|
|||
comp.run();
|
||||
}
|
||||
let e = TransitionError::TierDescentWitness {
|
||||
reason: "Tier2 descent requires the actor signing key (supply it via the ops surface or the keypair store)".to_owned(),
|
||||
reason: "Tier2 descent requires the actor signing key (supply it via the ops \
|
||||
surface or the keypair store)"
|
||||
.to_owned(),
|
||||
};
|
||||
log_failed_transition(
|
||||
&ctx.project_root,
|
||||
|
|
@ -764,7 +764,10 @@ mod tests {
|
|||
}
|
||||
|
||||
fn transition_log_entries(project_root: &Path) -> Vec<PathBuf> {
|
||||
let dir = project_root.join(".ontoref").join("artifacts").join("transition-log");
|
||||
let dir = project_root
|
||||
.join(".ontoref")
|
||||
.join("artifacts")
|
||||
.join("transition-log");
|
||||
if !dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
|
@ -891,10 +894,12 @@ mod tests {
|
|||
let between_quotes = body
|
||||
.split("source = \"")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split("\"")
|
||||
.next())
|
||||
.and_then(|s| s.split("\"").next())
|
||||
.unwrap_or("");
|
||||
assert!(!between_quotes.contains('"'), "source value must not contain bare double-quotes");
|
||||
assert!(
|
||||
!between_quotes.contains('"'),
|
||||
"source value must not contain bare double-quotes"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "substrate")]
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ use crate::validation::verdict::Verdict;
|
|||
///
|
||||
/// Slice envelope: `{ "adrs": [{"id": "…", "status": "…"}],
|
||||
/// "proofs": [{"evidence_adr": "…"}] }`.
|
||||
/// Returns `Unknown` when the slice was not loaded (slice loader not yet wired).
|
||||
/// Returns `Unknown` when the slice was not loaded (slice loader not yet
|
||||
/// wired).
|
||||
#[onto_validator(
|
||||
id = "adr_accepted_has_proof",
|
||||
description = "Every Accepted ADR is cited by at least one positioning Proof.",
|
||||
|
|
@ -91,9 +92,10 @@ fn adr_proposal_requires_description(slice: &Slice, _ctx: &ValidationCtx) -> Ver
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> ValidationCtx {
|
||||
ValidationCtx::synthetic("test", "op:adr-propose")
|
||||
}
|
||||
|
|
@ -218,8 +220,8 @@ mod tests {
|
|||
"adrs": [{"id": "adr-001", "status": "Accepted"}],
|
||||
"proofs": [{"evidence_adr": "adr-001"}]
|
||||
});
|
||||
let verdict =
|
||||
dispatch::dispatch("adr_accepted_has_proof", &slice, &ctx()).expect("must be registered");
|
||||
let verdict = dispatch::dispatch("adr_accepted_has_proof", &slice, &ctx())
|
||||
.expect("must be registered");
|
||||
assert_eq!(verdict, Verdict::Accept);
|
||||
}
|
||||
|
||||
|
|
@ -240,10 +242,11 @@ mod tests {
|
|||
/// in NCL but its Rust fn was never registered via `#[onto_validator]`.
|
||||
#[test]
|
||||
fn ncl_predicate_refs_all_registered() {
|
||||
use crate::validation::dispatch;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::validation::dispatch;
|
||||
|
||||
// `CARGO_MANIFEST_DIR` = code/crates/ontoref-ops
|
||||
// Three parents up → constellation root (ontoref/)
|
||||
let catalog = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
|
|
@ -293,8 +296,8 @@ mod tests {
|
|||
|
||||
assert!(
|
||||
registered.contains(&predicate_ref),
|
||||
"NCL predicate_ref '{}' (from {}) is not registered in the Rust inventory.\n\
|
||||
Registered ids: {:?}",
|
||||
"NCL predicate_ref '{}' (from {}) is not registered in the Rust \
|
||||
inventory.\nRegistered ids: {:?}",
|
||||
predicate_ref,
|
||||
path.display(),
|
||||
registered
|
||||
|
|
|
|||
|
|
@ -78,15 +78,17 @@ where
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ontoref_derive::onto_validator;
|
||||
|
||||
use super::*;
|
||||
use crate::validation::registry::ValidatorEntry;
|
||||
use ontoref_derive::onto_validator;
|
||||
|
||||
/// Dummy validator registered via the macro so the inventory iter
|
||||
/// has at least one entry to discover during the gate test.
|
||||
#[onto_validator(
|
||||
id = "dummy_always_accept",
|
||||
description = "Reference validator that accepts every input — exists to verify the registration path.",
|
||||
description = "Reference validator that accepts every input — exists to verify the \
|
||||
registration path.",
|
||||
category = "Structural",
|
||||
predicate_ref = "dummy_always_accept"
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ impl PipelineWitness {
|
|||
/// reconstruct this from the same inputs to verify the signature.
|
||||
pub fn canonical_payload(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(
|
||||
self.op_id.len() + self.state_root.len() + self.effects_hash.len() + self.extra_payload.len() + 16,
|
||||
self.op_id.len()
|
||||
+ self.state_root.len()
|
||||
+ self.effects_hash.len()
|
||||
+ self.extra_payload.len()
|
||||
+ 16,
|
||||
);
|
||||
buf.extend_from_slice(b"ontoref-witness-v1");
|
||||
buf.extend_from_slice(self.op_id.as_bytes());
|
||||
|
|
@ -54,10 +58,9 @@ impl PipelineWitness {
|
|||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`SignatureError::InvalidPublicKey`] if the actor's key is
|
||||
/// malformed.
|
||||
/// - [`SignatureError::BadSignature`] if the signature does not
|
||||
/// verify the canonical payload.
|
||||
/// - [`SignatureError::InvalidPublicKey`] if the actor's key is malformed.
|
||||
/// - [`SignatureError::BadSignature`] if the signature does not verify the
|
||||
/// canonical payload.
|
||||
pub fn verify(&self) -> Result<(), SignatureError> {
|
||||
let payload = self.canonical_payload();
|
||||
self.actor.verify(&payload, &self.signature)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ fn propose_adr_writes_adr_file_and_signs_witness() {
|
|||
assert_eq!(result.witness.witness_shape, "adr_proposed");
|
||||
result.witness.verify().expect("witness verifies");
|
||||
|
||||
let adr_path = tmp.path().join(".ontoref/adrs/adr-099-cross-domain-pilot.ncl");
|
||||
let adr_path = tmp
|
||||
.path()
|
||||
.join(".ontoref/adrs/adr-099-cross-domain-pilot.ncl");
|
||||
assert!(adr_path.exists(), "ADR file must be written");
|
||||
let body = std::fs::read_to_string(&adr_path).expect("read adr");
|
||||
assert!(body.contains("status = 'Proposed"));
|
||||
|
|
@ -89,8 +91,7 @@ fn propose_adr_rejects_empty_ondaod_with_no_rationale() {
|
|||
"synthesis_state": ""
|
||||
}
|
||||
});
|
||||
let err = dispatch_op("propose_adr", &inputs, &mut ctx)
|
||||
.expect_err("empty ondaod must reject");
|
||||
let err = dispatch_op("propose_adr", &inputs, &mut ctx).expect_err("empty ondaod must reject");
|
||||
assert!(matches!(err, OpError::ValidatorRejected { .. }));
|
||||
}
|
||||
|
||||
|
|
@ -106,8 +107,8 @@ fn propose_adr_rejects_malformed_id() {
|
|||
"decision": "x",
|
||||
"ondaod_evaluation": { "engaged_tensions": ["t"], "synthesis_state": "x" }
|
||||
});
|
||||
let err = dispatch_op("propose_adr", &inputs, &mut ctx)
|
||||
.expect_err("malformed adr id must reject");
|
||||
let err =
|
||||
dispatch_op("propose_adr", &inputs, &mut ctx).expect_err("malformed adr id must reject");
|
||||
assert!(matches!(err, OpError::OpBody { .. }));
|
||||
}
|
||||
|
||||
|
|
@ -153,8 +154,8 @@ fn update_ontology_node_updates_existing_node() {
|
|||
});
|
||||
let result = dispatch_op("update_ontology_node", &inputs, &mut ctx).expect("dispatch ok");
|
||||
assert_eq!(result.effects[0].kind, "Update");
|
||||
let body = std::fs::read_to_string(tmp.path().join(".ontoref/ontology/core.ncl"))
|
||||
.expect("read core");
|
||||
let body =
|
||||
std::fs::read_to_string(tmp.path().join(".ontoref/ontology/core.ncl")).expect("read core");
|
||||
assert!(body.contains("Updated description for the protocol invariant"));
|
||||
}
|
||||
|
||||
|
|
@ -189,8 +190,8 @@ fn manage_backlog_item_add_creates_entry() {
|
|||
result.witness.verify().expect("witness verifies");
|
||||
assert_eq!(result.effects[0].kind, "Insert");
|
||||
|
||||
let body =
|
||||
std::fs::read_to_string(tmp.path().join(".ontoref/reflection/backlog.ncl")).expect("read backlog");
|
||||
let body = std::fs::read_to_string(tmp.path().join(".ontoref/reflection/backlog.ncl"))
|
||||
.expect("read backlog");
|
||||
assert!(body.contains("Wire MCP surface to dispatch"));
|
||||
assert!(body.contains("Feature"));
|
||||
}
|
||||
|
|
@ -213,11 +214,10 @@ fn manage_backlog_item_update_mutates_existing() {
|
|||
"id": "bl-9001",
|
||||
"status": "Closed"
|
||||
});
|
||||
let result =
|
||||
dispatch_op("manage_backlog_item", &update_inputs, &mut ctx).expect("update ok");
|
||||
let result = dispatch_op("manage_backlog_item", &update_inputs, &mut ctx).expect("update ok");
|
||||
assert_eq!(result.effects[0].kind, "Update");
|
||||
let body =
|
||||
std::fs::read_to_string(tmp.path().join(".ontoref/reflection/backlog.ncl")).expect("read backlog");
|
||||
let body = std::fs::read_to_string(tmp.path().join(".ontoref/reflection/backlog.ncl"))
|
||||
.expect("read backlog");
|
||||
assert!(body.contains("Closed"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@ fn transition_adr_accept_requires_ondaod() {
|
|||
});
|
||||
let err = dispatch_op("transition_adr", &inputs, &mut c)
|
||||
.expect_err("accept without ondaod must reject");
|
||||
assert!(matches!(err, ontoref_ops::OpError::ValidatorRejected { .. }));
|
||||
assert!(matches!(
|
||||
err,
|
||||
ontoref_ops::OpError::ValidatorRejected { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -75,9 +78,12 @@ fn transition_adr_unsupported_target_rejects() {
|
|||
"new_status": "Withdrawn",
|
||||
"ondaod_evaluation": { "engaged_tensions": ["x"], "synthesis_state": "x" }
|
||||
});
|
||||
let err = dispatch_op("transition_adr", &inputs, &mut c)
|
||||
.expect_err("unsupported target must reject");
|
||||
assert!(matches!(err, ontoref_ops::OpError::ValidatorRejected { .. }));
|
||||
let err =
|
||||
dispatch_op("transition_adr", &inputs, &mut c).expect_err("unsupported target must reject");
|
||||
assert!(matches!(
|
||||
err,
|
||||
ontoref_ops::OpError::ValidatorRejected { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// ── update_ontology_edge ──────────────────────────────────────────────────
|
||||
|
|
@ -105,9 +111,12 @@ fn update_ontology_edge_rejects_self_loop() {
|
|||
"to": "x",
|
||||
"kind": "DependsOn",
|
||||
});
|
||||
let err = dispatch_op("update_ontology_edge", &inputs, &mut c)
|
||||
.expect_err("self-loop must reject");
|
||||
assert!(matches!(err, ontoref_ops::OpError::ValidatorRejected { .. }));
|
||||
let err =
|
||||
dispatch_op("update_ontology_edge", &inputs, &mut c).expect_err("self-loop must reject");
|
||||
assert!(matches!(
|
||||
err,
|
||||
ontoref_ops::OpError::ValidatorRejected { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -124,9 +133,12 @@ fn update_ontology_edge_rejects_1_step_cycle() {
|
|||
"to": "b",
|
||||
"kind": "DependsOn",
|
||||
});
|
||||
let err = dispatch_op("update_ontology_edge", &inputs, &mut c)
|
||||
.expect_err("1-step cycle must reject");
|
||||
assert!(matches!(err, ontoref_ops::OpError::ValidatorRejected { .. }));
|
||||
let err =
|
||||
dispatch_op("update_ontology_edge", &inputs, &mut c).expect_err("1-step cycle must reject");
|
||||
assert!(matches!(
|
||||
err,
|
||||
ontoref_ops::OpError::ValidatorRejected { .. }
|
||||
));
|
||||
}
|
||||
|
||||
// ── sync_apply ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -79,7 +79,11 @@ fn build_ctx(project_root: std::path::PathBuf) -> OpContext {
|
|||
fn happy_path_full_pipeline() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let project_root = tmp.path().to_path_buf();
|
||||
std::fs::create_dir_all(ontoref_ontology::layout::resolve_section(&project_root, "ontology")).expect("mkdir .ontology");
|
||||
std::fs::create_dir_all(ontoref_ontology::layout::resolve_section(
|
||||
&project_root,
|
||||
"ontology",
|
||||
))
|
||||
.expect("mkdir .ontology");
|
||||
|
||||
let mut ctx = build_ctx(project_root.clone());
|
||||
let initial_root = ctx.commit_layer.root();
|
||||
|
|
@ -97,8 +101,14 @@ fn happy_path_full_pipeline() {
|
|||
|
||||
// ── substrate ───────────────────────────────────────────────────────────
|
||||
let new_root = ctx.commit_layer.root();
|
||||
assert_eq!(new_root, result.witness.state_root, "witness binds to current state_root");
|
||||
assert_ne!(new_root, initial_root, "state_root must advance after the op");
|
||||
assert_eq!(
|
||||
new_root, result.witness.state_root,
|
||||
"witness binds to current state_root"
|
||||
);
|
||||
assert_ne!(
|
||||
new_root, initial_root,
|
||||
"state_root must advance after the op"
|
||||
);
|
||||
|
||||
// ── typed state ────────────────────────────────────────────────────────
|
||||
let dim = ctx.state.as_ref().unwrap().dimensions.first().unwrap();
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@
|
|||
//! log from disk and, trusting nothing but the actor's public key:
|
||||
//!
|
||||
//! 1. verifies every persisted operation's signature (the oplog body);
|
||||
//! 2. reconstructs each `PipelineWitness` from its payload and verifies
|
||||
//! its own Ed25519 signature;
|
||||
//! 2. reconstructs each `PipelineWitness` from its payload and verifies its
|
||||
//! own Ed25519 signature;
|
||||
//! 3. replays the witness chain — applying each witness's embedded
|
||||
//! state-changing op to a fresh commit layer — and confirms the
|
||||
//! reconstructed `state_root` reproduces the one the witness attests,
|
||||
//! at every step.
|
||||
//! reconstructed `state_root` reproduces the one the witness attests, at
|
||||
//! every step.
|
||||
//!
|
||||
//! This is the executable proof of the `vp-004` provenance claim.
|
||||
|
||||
|
|
@ -41,9 +41,8 @@ fn full_cycle_dispatch_persist_replay_verify() {
|
|||
// verifier reopens the file — a genuine fresh-process boundary.
|
||||
let attested_roots: Vec<[u8; 32]> = {
|
||||
let oplog = Arc::new(OpLog::open(&oplog_path).expect("open oplog"));
|
||||
let mut ctx =
|
||||
OpContext::new_in_memory("developer", "", SigningKey::from_bytes(&SEED))
|
||||
.with_oplog(Arc::clone(&oplog));
|
||||
let mut ctx = OpContext::new_in_memory("developer", "", SigningKey::from_bytes(&SEED))
|
||||
.with_oplog(Arc::clone(&oplog));
|
||||
|
||||
let mut roots = Vec::new();
|
||||
for topic in ["structure-that-remembers", "witness-as-axis-seam"] {
|
||||
|
|
|
|||
|
|
@ -76,9 +76,7 @@ impl QueryEngine {
|
|||
if !visited.insert(node.clone()) {
|
||||
continue;
|
||||
}
|
||||
let next = self
|
||||
.triples
|
||||
.entity_attr(&node, ref_attr, as_of, valid_at)?;
|
||||
let next = self.triples.entity_attr(&node, ref_attr, as_of, valid_at)?;
|
||||
if let Some(Value::Ref(target)) = next {
|
||||
frontier.push(target);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_query::QueryEngine;
|
||||
use ontoref_triples::TripleStore;
|
||||
use ontoref_types::{
|
||||
operation::{OpBody, OpPayload, Operation},
|
||||
AttrId, EntityId, Hlc, PublicKey, Value,
|
||||
};
|
||||
|
||||
use ontoref_query::QueryEngine;
|
||||
|
||||
fn signing_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[13u8; 32])
|
||||
}
|
||||
|
|
@ -147,10 +146,8 @@ fn transitive_closure_respects_as_of_horizon() {
|
|||
Hlc::new(30, 0),
|
||||
)
|
||||
.unwrap();
|
||||
let expected_late: std::collections::HashSet<EntityId> = ["a", "b", "c"]
|
||||
.iter()
|
||||
.map(|n| EntityId::new(*n))
|
||||
.collect();
|
||||
let expected_late: std::collections::HashSet<EntityId> =
|
||||
["a", "b", "c"].iter().map(|n| EntityId::new(*n)).collect();
|
||||
assert_eq!(late, expected_late);
|
||||
}
|
||||
|
||||
|
|
@ -268,8 +265,5 @@ fn subscription_delivers_op_payload_to_handler() {
|
|||
.unwrap();
|
||||
|
||||
let seen = captured.lock().unwrap();
|
||||
assert_eq!(
|
||||
*seen,
|
||||
vec![EntityId::new("alpha"), EntityId::new("beta")]
|
||||
);
|
||||
assert_eq!(*seen, vec![EntityId::new("alpha"), EntityId::new("beta")]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,10 @@ impl FilesystemSync {
|
|||
/// # Errors
|
||||
///
|
||||
/// IO error when the local peer directory cannot be created.
|
||||
pub fn new(base_dir: impl Into<PathBuf>, peer_id: impl Into<String>) -> Result<Self, SyncError> {
|
||||
pub fn new(
|
||||
base_dir: impl Into<PathBuf>,
|
||||
peer_id: impl Into<String>,
|
||||
) -> Result<Self, SyncError> {
|
||||
let base_dir = base_dir.into();
|
||||
let peer_id = peer_id.into();
|
||||
let local = base_dir.join(&peer_id);
|
||||
|
|
@ -120,7 +123,10 @@ impl FilesystemSync {
|
|||
///
|
||||
/// IO error on write.
|
||||
pub fn publish_body(&self, op_id: &str, body: &[u8]) -> Result<(), SyncError> {
|
||||
let path = self.base_dir.join(&self.peer_id).join(format!("{op_id}.body"));
|
||||
let path = self
|
||||
.base_dir
|
||||
.join(&self.peer_id)
|
||||
.join(format!("{op_id}.body"));
|
||||
std::fs::write(&path, body).map_err(|e| SyncError::Io {
|
||||
context: format!("writing body to {}", path.display()),
|
||||
source: e,
|
||||
|
|
@ -254,6 +260,8 @@ fn filesystem_default() {
|
|||
assert!(missing.is_none());
|
||||
|
||||
// Subscribing to a non-existent peer fails clearly.
|
||||
let err = bob.subscribe_peer("nobody").expect_err("subscribe must fail");
|
||||
let err = bob
|
||||
.subscribe_peer("nobody")
|
||||
.expect_err("subscribe must fail");
|
||||
assert!(matches!(err, SyncError::Io { .. }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ pub enum Error {
|
|||
/// An identifier exceeded the 65535-byte key encoding limit.
|
||||
#[error("identifier too long ({0} bytes)")]
|
||||
IdentifierTooLong(usize),
|
||||
/// A stored EAV row's key bytes did not decode to a valid (attr, hlc) tuple.
|
||||
/// A stored EAV row's key bytes did not decode to a valid (attr, hlc)
|
||||
/// tuple.
|
||||
#[error("corrupt key in eav table")]
|
||||
CorruptKey,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ pub(crate) fn hlc_from_be(bytes: &[u8; 12]) -> Hlc {
|
|||
}
|
||||
|
||||
pub(crate) fn value_hash(value: &Value) -> [u8; 32] {
|
||||
let bytes =
|
||||
canonical::encode(value).expect("canonical encoding never fails for owned types");
|
||||
let bytes = canonical::encode(value).expect("canonical encoding never fails for owned types");
|
||||
*blake3::hash(&bytes).as_bytes()
|
||||
}
|
||||
|
||||
|
|
@ -57,12 +56,7 @@ pub(crate) fn eav_prefix_entity_attr(entity: &str, attr: &str) -> Result<Vec<u8>
|
|||
Ok(key)
|
||||
}
|
||||
|
||||
pub(crate) fn vae_key(
|
||||
attr: &str,
|
||||
value_h: [u8; 32],
|
||||
entity: &str,
|
||||
tx: Hlc,
|
||||
) -> Result<Vec<u8>> {
|
||||
pub(crate) fn vae_key(attr: &str, value_h: [u8; 32], entity: &str, tx: Hlc) -> Result<Vec<u8>> {
|
||||
let mut key = Vec::with_capacity(2 + attr.len() + 32 + 2 + entity.len() + 12);
|
||||
push_len_str(&mut key, attr)?;
|
||||
key.extend_from_slice(&value_h);
|
||||
|
|
@ -78,7 +72,8 @@ pub(crate) fn vae_prefix_attr_value(attr: &str, value_h: [u8; 32]) -> Result<Vec
|
|||
Ok(key)
|
||||
}
|
||||
|
||||
/// Parse the (attr, tx_time) suffix of an EAV key whose entity prefix is `entity`.
|
||||
/// Parse the (attr, tx_time) suffix of an EAV key whose entity prefix is
|
||||
/// `entity`.
|
||||
pub(crate) fn parse_eav_key_attr_tx(key: &[u8], entity: &str) -> Result<(String, Hlc)> {
|
||||
let entity_prefix_len = 2 + entity.len();
|
||||
if key.len() < entity_prefix_len + 2 + 12 {
|
||||
|
|
@ -98,7 +93,8 @@ pub(crate) fn parse_eav_key_attr_tx(key: &[u8], entity: &str) -> Result<(String,
|
|||
Ok((attr, hlc_from_be(&tx_bytes)))
|
||||
}
|
||||
|
||||
/// Parse the (entity, tx_time) suffix of a VAE key whose (attr, value_hash) prefix is fixed.
|
||||
/// Parse the (entity, tx_time) suffix of a VAE key whose (attr, value_hash)
|
||||
/// prefix is fixed.
|
||||
pub(crate) fn parse_vae_key_entity_tx(key: &[u8], prefix_len: usize) -> Result<(String, Hlc)> {
|
||||
if key.len() < prefix_len + 2 + 12 {
|
||||
return Err(Error::CorruptKey);
|
||||
|
|
@ -117,7 +113,8 @@ pub(crate) fn parse_vae_key_entity_tx(key: &[u8], prefix_len: usize) -> Result<(
|
|||
Ok((entity, hlc_from_be(&tx_bytes)))
|
||||
}
|
||||
|
||||
/// Extract the tx_time from an EAV key whose `(entity, attr)` prefix is `entity_attr_prefix`.
|
||||
/// Extract the tx_time from an EAV key whose `(entity, attr)` prefix is
|
||||
/// `entity_attr_prefix`.
|
||||
pub(crate) fn parse_eav_key_tx_only(key: &[u8], entity_attr_prefix_len: usize) -> Result<Hlc> {
|
||||
if key.len() != entity_attr_prefix_len + 12 {
|
||||
return Err(Error::CorruptKey);
|
||||
|
|
|
|||
|
|
@ -174,7 +174,12 @@ impl TripleStore {
|
|||
continue;
|
||||
}
|
||||
|
||||
let candidate = (eav_row.valid_from, tx_time, eav_row.value, eav_row.retracted);
|
||||
let candidate = (
|
||||
eav_row.valid_from,
|
||||
tx_time,
|
||||
eav_row.value,
|
||||
eav_row.retracted,
|
||||
);
|
||||
let better = match winners.get(&attr) {
|
||||
None => true,
|
||||
Some((current_vf, current_tx, _, _)) => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ontoref_triples::TripleStore;
|
||||
use ontoref_types::{
|
||||
operation::{OpBody, OpPayload, Operation},
|
||||
AttrId, EntityId, Hlc, PublicKey, Value,
|
||||
};
|
||||
|
||||
use ontoref_triples::TripleStore;
|
||||
|
||||
fn signing_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[9u8; 32])
|
||||
}
|
||||
|
|
@ -53,9 +52,7 @@ fn forward_index_returns_attrs_for_entity() {
|
|||
))
|
||||
.unwrap();
|
||||
|
||||
let attrs = store
|
||||
.entity_attrs(&EntityId::new("alice"), tx, tx)
|
||||
.unwrap();
|
||||
let attrs = store.entity_attrs(&EntityId::new("alice"), tx, tx).unwrap();
|
||||
let map: HashMap<String, Value> = attrs
|
||||
.into_iter()
|
||||
.map(|(attr, value)| (attr.as_str().to_string(), value))
|
||||
|
|
@ -205,14 +202,10 @@ fn as_of_filters_to_tx_time_horizon() {
|
|||
.apply(&assert_op("e", vec![("v", Value::Int(2))], tx2, None))
|
||||
.unwrap();
|
||||
|
||||
let at_tx1 = store
|
||||
.entity_attrs(&EntityId::new("e"), tx1, tx1)
|
||||
.unwrap();
|
||||
let at_tx1 = store.entity_attrs(&EntityId::new("e"), tx1, tx1).unwrap();
|
||||
assert_eq!(at_tx1[0].1, Value::Int(1));
|
||||
|
||||
let at_tx2 = store
|
||||
.entity_attrs(&EntityId::new("e"), tx2, tx2)
|
||||
.unwrap();
|
||||
let at_tx2 = store.entity_attrs(&EntityId::new("e"), tx2, tx2).unwrap();
|
||||
assert_eq!(at_tx2[0].1, Value::Int(2));
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +333,12 @@ fn retract_hides_attribute_in_query_view() {
|
|||
|
||||
// Assert e.v = 1 at tx=10, then retract e.v at tx=20.
|
||||
store
|
||||
.apply(&assert_op("e", vec![("v", Value::Int(1))], Hlc::new(10, 0), None))
|
||||
.apply(&assert_op(
|
||||
"e",
|
||||
vec![("v", Value::Int(1))],
|
||||
Hlc::new(10, 0),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
store
|
||||
.apply(&retract_op("e", &["v"], Hlc::new(20, 0)))
|
||||
|
|
@ -364,15 +362,31 @@ fn reassert_after_retract_restores_attribute() {
|
|||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = TripleStore::open(dir.path().join("triples.redb")).unwrap();
|
||||
store
|
||||
.apply(&assert_op("e", vec![("v", Value::Int(1))], Hlc::new(10, 0), None))
|
||||
.apply(&assert_op(
|
||||
"e",
|
||||
vec![("v", Value::Int(1))],
|
||||
Hlc::new(10, 0),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
store.apply(&retract_op("e", &["v"], Hlc::new(20, 0))).unwrap();
|
||||
store
|
||||
.apply(&assert_op("e", vec![("v", Value::Int(2))], Hlc::new(30, 0), None))
|
||||
.apply(&retract_op("e", &["v"], Hlc::new(20, 0)))
|
||||
.unwrap();
|
||||
store
|
||||
.apply(&assert_op(
|
||||
"e",
|
||||
vec![("v", Value::Int(2))],
|
||||
Hlc::new(30, 0),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let now = store
|
||||
.entity_attrs(&EntityId::new("e"), Hlc::new(30, 0), Hlc::new(30, 0))
|
||||
.unwrap();
|
||||
assert_eq!(now, vec![(AttrId::new("v"), Value::Int(2))], "re-assert after retract wins by tx_time");
|
||||
assert_eq!(
|
||||
now,
|
||||
vec![(AttrId::new("v"), Value::Int(2))],
|
||||
"re-assert after retract wins by tx_time"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
//! Determinism depends on two properties:
|
||||
//! 1. The encoder MUST NOT emit indefinite-length items.
|
||||
//! 2. Map keys MUST appear in lexicographic byte order (CBOR canonical form,
|
||||
//! RFC 8949 §4.2.1). `ciborium` respects struct field declaration order;
|
||||
//! the type-level invariant is: every serialized struct declares fields in
|
||||
//! RFC 8949 §4.2.1). `ciborium` respects struct field declaration order; the
|
||||
//! type-level invariant is: every serialized struct declares fields in
|
||||
//! lexicographic order. Each new struct added must respect this.
|
||||
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! Content-addressed identifier for operations.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 32-byte blake3 content address of a canonically-encoded operation.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct OpId(pub [u8; 32]);
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ pub enum OpPayload {
|
|||
/// witness to its DAG position (bl-022): reordering the log mutates
|
||||
/// the signed body and fails `verify_signature`.
|
||||
Witness {
|
||||
/// Blake3 hash over the canonical encoding of the attested op's effects.
|
||||
/// Blake3 hash over the canonical encoding of the attested op's
|
||||
/// effects.
|
||||
effects_hash: [u8; 32],
|
||||
/// Domain payload the op body emitted (canonical bytes for replay).
|
||||
extra_payload: Vec<u8>,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue