Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ details, release history over commit history.
scalar; a type name may not overwrite an existing `stats --json` key; and
ill-typed `descriptions`, `starter_bodies`, and `guidance` values are skipped
with `artifact-spec-skipped` instead of being admitted as empty.
- ADR-150 composition correctness: a type override's rationale resolves
across the declaring corpus instead of only the directory or `--top-level`
scope a command names, and ignores case as ADR-137 rationales do; two
declarations that differ only in JSON key order are one type instead of a
`corpus-federation-artifact-type-conflict`; the composed-registry memo is
keyed on the federation topology as well as the configs, so a long-running
process sees a changed parent graph; `decided new` and `decided migrate` in a
version-2 repository again count identifiers in sibling top-level
directories as issued; and `artifact_spec_bundles` reports `source: null`,
not the `prefer: local` keyword, for a local bundle without `corpus.source`.
- `decided new` and `decided migrate` in a repository with a version-2
federation manifest, which failed with `federated-corpus-snapshot-failed`
because the identifier-collision scan composed the repository root, a path
Expand Down
35 changes: 24 additions & 11 deletions decisions/designs/third-party-artifact-extensibility.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
schema_version: 1
id: RAC-KVTSPM9V9VDQ
Expand Down Expand Up @@ -322,8 +322,9 @@
source).
3. Group candidates by name in order of first appearance. One distinct
content per name is admitted as is; elements that compare equal as
admitted elements are silent duplicates, so key order and whitespace in a
bundle file cannot manufacture a conflict. Two or more distinct contents
admitted data are silent duplicates (map-like fields compare as maps, lists
in order, because list order is rendered order), so key order and
whitespace in a bundle file cannot manufacture a conflict. Two or more distinct contents
are a collision: with no override at this source the composition stops
with `corpus-federation-artifact-type-conflict`, naming the type and every
declaring source; with an override, the candidate declared by `prefer`
Expand All @@ -335,25 +336,37 @@
family already uses for a malformed ADR-137 declaration.
5. The effective registry is the built-ins in registry order followed by the
surviving elements in first-appearance order. The winner of a collision is
what descendants inherit; a descendant that declares yet another content
for the name collides afresh and needs its own override, mirroring the
override chains of ADR-147.
what descendants inherit through this source; a descendant that declares
yet another content for the name, or that reaches a losing declaration
again through another parent (a diamond), collides afresh and needs its
own override, mirroring the override chains and explicit diamond
convergence of ADR-147. A middle node's resolution governs its own view
only; it is not carried past a sibling path.

**Rationale check.** `rationale` must resolve to exactly one live local
Decision of the declaring source: one item whose origin is that source and
whose canonical identifier matches, classified `decision`, and live by the
same predicate the artifact overrides use. Items only exist after the
whose canonical identifier matches ignoring case (as ADR-137 rationales do),
classified `decision`, and live by the same predicate the artifact overrides
use. An inherited source's corpus is captured whole; the root's captured
files may be only the command's directory or `--top-level` scope, so a
root-owned rationale not found there is resolved over a walk of the root's
working tree with every materialised parent excluded. An override is
config-level policy, so its validity cannot depend on the directory a
command names. Items only exist after the
closure's files are parsed, so this check runs immediately after parsing in
both composition functions; a failure is `corpus-federation-invalid-override`
and fails the composition like any other override defect. The registry is
installed before parsing so inherited types classify; a failed rationale
check stops the run before anything is served, so the ordering is not
observable.

**Process slot.** Built registries are memoised by a key over every source's
`(source, config bytes)` in composition order, which fixes the pins and the
overrides; the local-only registry of section 1 uses the same key shape with
one frame. Bundle bytes are re-read and re-hashed against their pin on every
**Process slot.** Composed registries are memoised by a key over the root
source and, per source in source order, its identity, layer, config bytes,
and canonical parent list: the configs fix the pins and the overrides, and
the parent lists fix the topology, which the manifests carry rather than the
configs, so a changed graph under byte-identical configs recomposes. The
local-only registry of section 1 keys on its one `(source, config bytes)`
frame. Bundle bytes are re-read and re-hashed against their pin on every
sync before the memo is consulted, so a bundle edited without a re-pin fails
closed on the next command or request rather than serving the previous
registry. `sync_registry` keeps an installed federated registry while the
Expand Down
4 changes: 3 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,9 @@ lists its declared types after the built-ins; `schema <type> --template` and
federated repository the types inherited from parents (ADR-150) are part of
the effective registry: `decided new` composes the closure first and its
identifier-collision scan covers the composed closure of the top-level corpus
directory holding the target; `schema` and `templates` list them when given
directory holding the target plus every other file in the repository, so an
identifier issued in a sibling directory or by a replaced parent artifact is
not reused; `schema` and `templates` list them when given
`--corpus <dir>`, which composes that directory's closure, and list the
local registry only when run without it. `--corpus` names a corpus directory;
the root of a version-1 child is its corpus, while the root of a version-2
Expand Down
20 changes: 14 additions & 6 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ carry the bundle's own digest, so no new pin is needed and a parent cannot
change its types without changing the digest the child verifies.

- **Identical declarations are silent; different ones are an error.** Two
sources declaring the same element content are one type. Two sources
sources declaring the same element content are one type; content is
compared as data, so JSON key order does not matter while list order (the
rendered order of sections and values) does. Two sources
declaring the same name with different content stop every command and MCP
tool with `corpus-federation-artifact-type-conflict`, naming the type and
the sources, in the same failure class as a duplicate parent or a cycle.
Expand All @@ -166,20 +168,26 @@ change its types without changing the digest the child verifies.
rationale: APP-KWJ9D3C1S10N # a live local Decision
```

`rationale` must resolve to exactly one Accepted, unretired Decision of the
declaring corpus; a name may be overridden at most once; an override for a
`rationale` must resolve, ignoring case, to exactly one Accepted, unretired
Decision of the declaring corpus. An override is config-level policy, so the
Decision is looked up across that whole corpus (a materialised parent's tree
excluded) whatever directory or `--top-level` scope a command names. A name
may be overridden at most once; an override for a
name that does not collide, a `prefer` outside the corpus's transitive
parents, or a preferred source that declares no candidate is
`corpus-federation-invalid-override`, the finding artifact overrides already
use. The winner is what the corpus's descendants inherit; a descendant that
declares yet another content collides afresh and needs its own override.
use. The winner is what the corpus's descendants inherit through it; a
descendant that declares yet another content, or that reaches the losing
declaration again through another parent (a diamond), collides afresh and
needs its own override, which may prefer any source in its inherited view.
- **A parent's bundle is verified on every command.** Its bytes are checked
against the digest in the parent's captured config before any element is
admitted; a mismatch is the parent-side `artifact-spec-bundle-digest-mismatch`,
reported with the parent's source, and fails composition.
- **Provenance is per source.** `decided validate --json` gains
`artifact_spec_bundles`: one entry per source that pinned a bundle, in
composition order, with `source`, `layer`, `path`, `digest`, `admitted`, and
composition order, with `source` (`null` for a local bundle whose config
declares no `corpus.source`), `layer`, `path`, `digest`, `admitted`, and
`warnings`. The single `artifact_spec_bundle` object stays for the local
bundle. The human output adds one `WARN` block per inherited bundle with
skipped elements, and `decided doctor` names the source in each inherited
Expand Down
24 changes: 24 additions & 0 deletions rust/decided/tests/spec_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -746,3 +746,27 @@ fn okf_types_are_emitted_as_safe_scalars() {
.unwrap();
assert!(policy.contains("type: Policy\n"), "{policy}");
}

#[test]
fn a_local_bundle_without_a_declared_source_reports_a_null_source() {
let root = fixture_copy("null-source");
let named = json(&run_in(&root, &["validate", "decisions", "--json"]));
assert_eq!(
named["artifact_spec_bundles"][0]["source"],
"asdecided/fixtures-spec-bundle"
);
// Without `corpus.source` the label is null, never the `prefer: local`
// keyword a source name could be mistaken for.
let config = root.join(".decided/config.yaml");
let text = fs::read_to_string(&config)
.unwrap()
.replace("corpus:\n source: asdecided/fixtures-spec-bundle\n", "");
assert!(!text.contains("corpus:"));
fs::write(&config, text).unwrap();
let output = run_in(&root, &["validate", "decisions", "--json"]);
let payload = json(&output);
let entry = &payload["artifact_spec_bundles"][0];
assert!(entry["source"].is_null(), "{entry}");
assert_eq!(entry["layer"], "local");
assert_eq!(entry["path"], ".decided/artifact-specs.json");
}
117 changes: 117 additions & 0 deletions rust/decided/tests/spec_federation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,68 @@ fn a_decision_backed_override_selects_the_preferred_declaration() {
assert_eq!(json(&output)["valid"], true);
}

#[test]
fn a_type_override_rationale_resolves_across_the_whole_local_corpus() {
let root = fixture_copy("override-scope");
let policy: serde_json::Value =
serde_json::from_slice(&fs::read(root.join(".decided/artifact-specs.json")).unwrap())
.unwrap();
let policy = policy["artifact_specs"][0].to_string();
write_and_repin(
&root,
".decided/artifact-specs.json",
".decided/config.yaml",
&[&policy, RUNBOOK_ALT],
);
// Rationale identifiers casefold, as ADR-137 artifact-override
// rationales do.
let lowered = LIVE_RATIONALE.to_lowercase();
append_overrides(&root, &[("runbook", "local", &lowered)]);
fs::create_dir_all(root.join("decisions/runbooks")).unwrap();
fs::write(
root.join("decisions/runbooks/rotate-keys.md"),
"---\nschema_version: 1\nid: SPC-000000000009\ntype: runbook\n---\n# Rotate Keys\n\n## Status\n\nActive\n\n## Purpose\n\nRotate credentials.\n",
)
.unwrap();

// The Decision lives in decisions/decisions/, outside every scope below
// but the first; a type override is config-level policy and resolves in
// the declaring corpus whatever directory a command names.
for args in [
&["validate", "decisions", "--json"][..],
&["validate", "decisions/runbooks", "--json"],
&["validate", "decisions", "--top-level", "--json"],
&["stats", "decisions/runbooks", "--json"],
&["doctor", "decisions/runbooks", "--json"],
] {
let output = run_in(&root, args);
assert_eq!(
output.status.code(),
Some(0),
"{args:?}: {}{}",
stdout(&output),
stderr(&output)
);
}
fs::create_dir_all(root.join("other")).unwrap();
let created = run_in(&root, &["new", "decision", "other/choice.md"]);
assert_eq!(created.status.code(), Some(0), "{}", stderr(&created));

// A parent's Decision is not a local rationale, in any scope.
let config = root.join(".decided/config.yaml");
let text = fs::read_to_string(&config)
.unwrap()
.replace(&lowered, "SPS-000000000002");
fs::write(&config, text).unwrap();
let output = run_in(&root, &["validate", "decisions/runbooks", "--json"]);
assert_eq!(output.status.code(), Some(1), "{}", stdout(&output));
assert!(
stdout(&output).contains(INVALID_OVERRIDE) && stdout(&output).contains("does not resolve"),
"{}",
stdout(&output)
);
}

#[test]
fn override_defects_fail_with_one_stable_code_each() {
let policy_of = |root: &Path| -> String {
Expand Down Expand Up @@ -937,3 +999,58 @@ fn historical_exports_classify_under_the_bundles_pinned_at_that_revision() {
));
assert_eq!(then, ["decision", "policy", "runbook"]);
}

#[test]
fn a_version_one_type_override_rationale_resolves_outside_the_command_scope() {
let repo = FederationRepo::new("spec-override-v-one");
let runbook = standards_runbook(&fixture());
let bundle = format!("{{\"artifact_specs\":[{runbook}]}}\n");
repo.write("vendor/standards/.decided/artifact-specs.json", &bundle);
repo.append(
"vendor/standards/.decided/config.yaml",
&format!(
"artifact_types:\n version: 1\n bundle:\n path: .decided/artifact-specs.json\n digest: sha256:{}\n",
sha256(bundle.as_bytes())
),
);
let local = format!("{{\"artifact_specs\":[{RUNBOOK_ALT}]}}\n");
repo.write(".decided/artifact-specs.json", &local);
repo.append(
".decided/config.yaml",
&format!(
"artifact_types:\n version: 1\n bundle:\n path: .decided/artifact-specs.json\n digest: sha256:{}\n overrides:\n - name: runbook\n prefer: local\n rationale: {}\n",
sha256(local.as_bytes()),
federation_support::CHILD_DECISION_ID.to_lowercase()
),
);
repo.write(
"decisions/runbooks/rotate-keys.md",
"---\nschema_version: 1\nid: APP-000000000009\ntype: runbook\n---\n# Rotate Keys\n\n## Status\n\nActive\n\n## Purpose\n\nRotate credentials.\n",
);
repo.activate();

// The rationale Decision sits in decisions/decisions/, which neither the
// subdirectory nor the top-level scope reaches.
for args in [
&["validate", "decisions", "--json"][..],
&["validate", "decisions/runbooks", "--json"],
&["validate", "decisions", "--top-level", "--json"],
] {
let output = repo.run(args);
assert_eq!(
output.status.code(),
Some(0),
"{args:?}: {}{}",
stdout(&output),
stderr(&output)
);
}
let payload = json(&repo.run(&["validate", "decisions/runbooks", "--json"]));
let row = payload["files"]
.as_array()
.unwrap()
.iter()
.find(|f| f["artifact_type"] == "runbook")
.expect("the local runbook classifies under the preferred declaration");
assert_eq!(row["status"], "valid");
}
5 changes: 3 additions & 2 deletions rust/rac-engine/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ fn spec_bundle_findings() -> Vec<DoctorFinding> {
problem: if inherited {
format!(
"inherited source '{}': {element} skipped: {}",
source.source, warning.message
source.source.as_deref().unwrap_or_default(),
warning.message
)
} else {
format!("{element} skipped: {}", warning.message)
Expand All @@ -194,7 +195,7 @@ fn spec_bundle_findings() -> Vec<DoctorFinding> {
"Fix the element in the spec bundle of '{}' and re-pin its digest in \
that corpus's .decided/config.yaml; the child inherits the parent's \
admitted types as pinned (ADR-150).",
source.source
source.source.as_deref().unwrap_or_default()
)
} else {
"Fix the element in the spec bundle and re-pin its digest in \
Expand Down
7 changes: 7 additions & 0 deletions rust/rac-engine/src/federated_corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,14 @@ pub fn compose_verified_generation_from_snapshot(
if let Some(registry) = registry {
crate::spec_composition::verify_override_rationales(
registry,
&verified.child_source,
local.iter().chain(inherited.iter()),
|| {
crate::spec_composition::root_tree_items(
&verified.child_repository_root,
std::slice::from_ref(&verified.materialisation_root),
)
},
)
.map_err(|error| spec_error(verified, error))?;
}
Expand Down
14 changes: 12 additions & 2 deletions rust/rac-engine/src/graph_federated_corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,18 @@ pub fn compose_verified_federation(
let registry = install_closure_registry(&federation)?;
let parsed = parse_and_validate_snapshots(&federation)?;
if let Some(registry) = registry {
crate::spec_composition::verify_override_rationales(registry, parsed.items.iter())
.map_err(|error| spec_error(&federation, error))?;
crate::spec_composition::verify_override_rationales(
registry,
&federation.root_source,
parsed.items.iter(),
|| {
crate::spec_composition::root_tree_items(
&federation.repository_root,
&federation.materialisation_roots,
)
},
)
.map_err(|error| spec_error(&federation, error))?;
}

validate_nested_v1(
Expand Down
3 changes: 2 additions & 1 deletion rust/rac-engine/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,8 @@ pub fn render_validate_dir_human(result: &DirectoryValidation) -> String {
{
lines.push(format!(
"WARN {}:{} (artifact spec bundle, inherited)",
source.source, source.bundle.pin.path
source.source.as_deref().unwrap_or_default(),
source.bundle.pin.path
));
for warning in &source.bundle.warnings {
let label = match &warning.name {
Expand Down
19 changes: 13 additions & 6 deletions rust/rac-engine/src/scaffold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,12 +672,19 @@ fn issued_ids(repository_root: &str, target_dir: &str) -> Result<HashSet<String>
let items = match graph_collision_scope(repository_root, target_dir)? {
// A version-2 graph rejects the repository root as a corpus path, so
// the collision set is the composed closure of the top-level corpus
// directory holding the target, inherited layer included: an
// identifier a parent already issued is not free either.
Some(directory) => crate::federated_corpus::load_composed_corpus(&directory, true)
.map_err(|error| ScaffoldError::MalformedRepositoryConfig(error.to_string()))?
.map(|corpus| corpus.effective().cloned().collect::<Vec<_>>())
.unwrap_or_default(),
// directory holding the target, inherited layer included (an
// identifier a parent already issued is not free either), plus a
// plain walk of the whole repository, so identifiers in sibling
// top-level directories and in replaced parent artifacts stay issued
// as they were before the graph existed.
Some(directory) => {
let mut items = crate::federated_corpus::load_composed_corpus(&directory, true)
.map_err(|error| ScaffoldError::MalformedRepositoryConfig(error.to_string()))?
.map(|corpus| corpus.effective().cloned().collect::<Vec<_>>())
.unwrap_or_default();
items.extend(crate::relationships::corpus_items(repository_root, true));
items
}
None => local_items(repository_root, true)?,
};
Ok(items
Expand Down
Loading
Loading