diff --git a/canon/standards/emission-cadence-security-profile_v0.1.md b/canon/standards/emission-cadence-security-profile_v0.1.md index d2f2302..077fd58 100644 --- a/canon/standards/emission-cadence-security-profile_v0.1.md +++ b/canon/standards/emission-cadence-security-profile_v0.1.md @@ -7,8 +7,8 @@ status: proposed version: "0.1" owner: net-kingdom created: "2026-09-04" -updated: "2026-09-05" -last_reviewed: "2026-09-05" +updated: "2026-09-28" +last_reviewed: "2026-09-28" review_interval: 3m scope: evidence-completeness validator: @@ -34,14 +34,21 @@ This profile imports that contract and defines only NetKingdom security obligations over conforming declarations. The import is InfoTechCanon `standard/emission-cadence`, document version -`0.1.0`, schema version `0.1` (published in canon 0.7.0; upstream status: draft). +`0.2.0`, schema version `0.1` (canon 0.7.0; upstream status: candidate). - Contract: `info-tech-canon/infospace/standards/emission-cadence/InfoTechCanonEmissionCadenceStandard.md` - Schema: `info-tech-canon/infospace/schemas/emission-cadence.schema.yaml` - Schema ID: `https://info-tech-canon.local/schemas/emission-cadence.schema.yaml` -- Reviewed schema revision: `b081d39da1353201f879ee6832d4e3e52b791c73` +- Reviewed contract/schema revision: `4d0851c3fca306538b53838421f4499baf352778` +- Owner-published candidate bundle digest: `b08b4d95fc4b0bd3` - Schema SHA-256: `6d52692eb1e4d1325e0d6062d95acded98beddaae5bcfe24c6f0e91f1b6be6ae` +The candidate bundle digest identifies the exported contract, including its +standard text and example; it is not the schema SHA-256. The September 28 +review confirmed that the wire schema is byte-identical to the earlier import. +InfoTechCanon corrected its adoption brief: `972c0b6701d1693f` identified the +0.1.0 draft bundle, not candidate 0.2.0. + The schema ID is an identifier; supply the file from the owner checkout, not a network download from that hostname. This profile remains proposed pending owner-instance migration and validation. The King's Guard draft is provenance, @@ -118,9 +125,26 @@ contract and refuses to profile a document that fails the imported schema. from the source's authoritative event-class inventory; they are not guesses by the checker. Rare load-bearing assertions imply load-bearing. -MUST failures or generic contract failures produce a non-zero exit. Missing -attributive declarations produce a SHOULD finding and succeed by default; -`--fail-on-should` is available for a stricter caller policy. +The report separates `contract_valid` (JSON Schema validity) from +`profile_assessed` and nullable `conformant`. A profile assessment is performed +only after schema validation and with a nonempty supplied inventory. The report +records those assertions under `inventory` and marks `assessment_scope` as +`supplied-inventory`; its result covers only that inventory, not independently +verified completeness or operational emission. + +Without inventory, default mode returns `profile_assessed: false`, +`conformant: null`, `assessment_scope: inventory-missing` and exit 2. Explicit +`--schema-only` returns `assessment_scope: schema-only` and exit 0 for a valid +schema instance, while leaving profile conformance unassessed. It cannot be +combined with inventory flags or `--fail-on-should`. Invalid schema/declaration +results return exit 1 with profile conformance unassessed. CLI/input errors +return exit 2. + +MUST failures from an assessed profile produce exit 1. Missing attributive +declarations produce a SHOULD finding and succeed by default; +`--fail-on-should` is available for a stricter caller policy. Callers must check +`profile_assessed == true` and `conformant == true` before claiming profile +success; an empty findings list or schema-only success is insufficient. ## 5. Adoption gate diff --git a/local-identity/emission-cadence-findings.md b/local-identity/emission-cadence-findings.md index d57150f..0c84695 100644 --- a/local-identity/emission-cadence-findings.md +++ b/local-identity/emission-cadence-findings.md @@ -1,7 +1,11 @@ # local-identity emission cadence — fit findings (INFO-WP-0029-T02) -Declaration: `local-identity/emission-cadence.yaml`, pinned to InfoTechCanon -emission contract digest `972c0b6701d1693f` (document 0.2.0, wire schema 0.1). +Declaration: `local-identity/emission-cadence.yaml`, currently pinned to +InfoTechCanon candidate bundle `b08b4d95fc4b0bd3` (document 0.2.0, wire schema +0.1). The September 21 evaluation used `972c0b6701d1693f`, then incorrectly +labelled 0.2.0 by the owner brief; that digest actually identifies the 0.1.0 +draft. The owner corrected the brief on September 22. The schema bytes are +unchanged, and the historical findings below remain valid. Emitter: `local-identity/src/local_identity/audit.py`. ## Validation (2026-09-21) @@ -39,3 +43,13 @@ To close the gap, `serve` would have to emit a periodic `nothing-to-report` heartbeat and a start/stop pair so that observers can bound the silence. That is a code change to a bootstrap-only tool and is not planned. This file records the incompatibility as the adoption result. + +## Pin correction and revalidation — 2026-09-28 + +The current declaration now uses the owner-published candidate bundle digest +`b08b4d95fc4b0bd3`. Schema SHA-256 remains +`6d52692eb1e4d1325e0d6062d95acded98beddaae5bcfe24c6f0e91f1b6be6ae`. +Revalidated against the owner schema with both documented classes explicitly +supplied as rare load-bearing: generic-valid, with exactly two +`rare-heartbeat-missing` findings. All 15 focused checker tests pass. No +heartbeat, observer feed or runtime emission change is implied by this pin fix. diff --git a/local-identity/emission-cadence.yaml b/local-identity/emission-cadence.yaml index c74fde2..c11db91 100644 --- a/local-identity/emission-cadence.yaml +++ b/local-identity/emission-cadence.yaml @@ -1,5 +1,5 @@ # Source-owned Emission Cadence declaration for local-identity (INFO-WP-0029-T02). -# Pinned to InfoTechCanon emission contract digest 972c0b6701d1693f +# Pinned to InfoTechCanon emission contract digest b08b4d95fc4b0bd3 # (document 0.2.0, wire schema 0.1). # # This states intended cadence only. It is not evidence that the events are @@ -12,7 +12,7 @@ source: net-kingdom stream_id: net-kingdom.local-identity.audit extensions: netkingdom: - contract_digest: 972c0b6701d1693f + contract_digest: b08b4d95fc4b0bd3 contract_document_version: 0.2.0 sources: - source_id: net-kingdom.local-identity.audit.token-issued diff --git a/tools/emission-cadence-profile/README.md b/tools/emission-cadence-profile/README.md index 48676ae..48f1639 100644 --- a/tools/emission-cadence-profile/README.md +++ b/tools/emission-cadence-profile/README.md @@ -9,6 +9,56 @@ classes passed with `--load-bearing`, `--rare-load-bearing`, and `--attributive` come from the source's authoritative inventory; the checker does not infer them from names, payloads, or observed traffic. +## Assessment modes and results + +A security-profile assessment requires at least one source-owned class +assertion. For example, to check local-identity's documented rare classes: + +```bash +python3 tools/emission-cadence-profile/emission_cadence_profile.py \ + --contract-schema ../info-tech-canon/infospace/schemas/emission-cadence.schema.yaml \ + --rare-load-bearing serve/token.token_issued \ + --rare-load-bearing revoke-token \ + local-identity/emission-cadence.yaml +``` + +This declaration currently fails both heartbeat obligations. To intentionally +check only its structure against the imported JSON Schema: + +```bash +python3 tools/emission-cadence-profile/emission_cadence_profile.py \ + --contract-schema ../info-tech-canon/infospace/schemas/emission-cadence.schema.yaml \ + --schema-only local-identity/emission-cadence.yaml +``` + +| Invocation/result | `contract_valid` | `profile_assessed` | `conformant` | Exit | +| --- | --- | --- | --- | --- | +| Valid schema, no inventory, default mode | true | false | null | 2 | +| Valid schema, explicit `--schema-only` | true | false | null | 0 | +| Invalid schema/declaration, either mode | false | false | null | 1 | +| Supplied inventory, profile passes | true | true | true | 0 | +| Supplied inventory, profile fails | true | true | false | 1 | + +`assessment_scope` is `schema-only`, `inventory-missing`, or +`supplied-inventory`. The report includes the exact sorted `inventory` +assertions. Profile results cover only those assertions: the checker cannot +prove that the caller supplied a complete inventory or that events are emitted +and observed. `contract_valid` means JSON Schema validity, not every semantic +rule in the generic standard; duplicate source IDs are checked during profile +assessment. + +`--schema-only` cannot be combined with class assertions or `--fail-on-should`. +Blank class arguments are rejected. Invalid CLI options and unreadable input +also exit 2. SHOULD findings remain advisory unless `--fail-on-should` is used. + +**Compatibility:** callers that previously omitted class arguments must now +choose schema-only validation or supply an inventory. `conformant` can be null; +automation claiming profile success must require `profile_assessed == true` +and `conformant == true`, rather than merely checking for no findings or an +exit code of zero. No evidence classification is inferred from the document. + +## Verification + Run its tests with: ```bash diff --git a/tools/emission-cadence-profile/emission_cadence_profile.py b/tools/emission-cadence-profile/emission_cadence_profile.py index 3f8e679..592a657 100644 --- a/tools/emission-cadence-profile/emission_cadence_profile.py +++ b/tools/emission-cadence-profile/emission_cadence_profile.py @@ -348,10 +348,17 @@ def build_report( rare_load_bearing: set[str], attributive: set[str], fail_on_should: bool = False, + schema_only: bool = False, ) -> dict[str, Any]: + inventory_supplied = bool(load_bearing or rare_load_bearing or attributive) + if schema_only and (inventory_supplied or fail_on_should): + raise ValueError("schema-only validation cannot include profile options") + if any(not name.strip() for name in load_bearing | rare_load_bearing | attributive): + raise ValueError("inventory event classes must not be blank") findings = _schema_findings(contract_schema, declaration) contract_valid = not findings - if contract_valid: + profile_assessed = contract_valid and inventory_supplied and not schema_only + if profile_assessed: findings.extend( evaluate_profile( declaration, @@ -362,17 +369,37 @@ def build_report( ) must_count = sum(item.level == "MUST" for item in findings) should_count = sum(item.level == "SHOULD" for item in findings) + assessment_scope = "supplied-inventory" if inventory_supplied else "inventory-missing" + if schema_only: + assessment_scope = "schema-only" return { "profile": PROFILE_ID, "contract_schema": contract_schema_path, "declaration": declaration_path, "contract_valid": contract_valid, - "conformant": must_count == 0 and (not fail_on_should or should_count == 0), + "profile_assessed": profile_assessed, + "assessment_scope": assessment_scope, + "inventory": { + "load_bearing": sorted(load_bearing), + "rare_load_bearing": sorted(rare_load_bearing), + "attributive": sorted(attributive), + }, + "conformant": ( + must_count == 0 and (not fail_on_should or should_count == 0) + if profile_assessed + else None + ), "summary": {"must": must_count, "should": should_count}, "findings": [asdict(item) for item in findings], } +def _event_class(value: str) -> str: + if not value.strip(): + raise argparse.ArgumentTypeError("event class must not be blank") + return value + + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Validate an imported emission-cadence declaration against the NetKingdom profile." @@ -380,16 +407,40 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("declaration", type=Path) parser.add_argument("--contract-schema", required=True, type=Path) parser.add_argument( - "--load-bearing", action="append", default=[], metavar="EVENT_CLASS" + "--schema-only", + action="store_true", + help="Validate only the supplied JSON Schema; do not assess security-profile conformance.", ) parser.add_argument( - "--rare-load-bearing", action="append", default=[], metavar="EVENT_CLASS" + "--load-bearing", + action="append", + default=[], + type=_event_class, + metavar="EVENT_CLASS", ) parser.add_argument( - "--attributive", action="append", default=[], metavar="EVENT_CLASS" + "--rare-load-bearing", + action="append", + default=[], + type=_event_class, + metavar="EVENT_CLASS", + ) + parser.add_argument( + "--attributive", + action="append", + default=[], + type=_event_class, + metavar="EVENT_CLASS", ) parser.add_argument("--fail-on-should", action="store_true") - return parser.parse_args(argv) + args = parser.parse_args(argv) + if args.schema_only and ( + args.load_bearing or args.rare_load_bearing or args.attributive or args.fail_on_should + ): + parser.error( + "--schema-only cannot be combined with profile inventory or --fail-on-should" + ) + return args def main(argv: list[str] | None = None) -> int: @@ -409,8 +460,15 @@ def main(argv: list[str] | None = None) -> int: rare_load_bearing=set(args.rare_load_bearing), attributive=set(args.attributive), fail_on_should=args.fail_on_should, + schema_only=args.schema_only, ) print(json.dumps(report, indent=2, sort_keys=True)) + if not report["contract_valid"]: + return 1 + if args.schema_only: + return 0 + if not report["profile_assessed"]: + return 2 return 0 if report["conformant"] else 1 diff --git a/tools/emission-cadence-profile/tests/test_emission_cadence_profile.py b/tools/emission-cadence-profile/tests/test_emission_cadence_profile.py index c68e80f..5c7462a 100644 --- a/tools/emission-cadence-profile/tests/test_emission_cadence_profile.py +++ b/tools/emission-cadence-profile/tests/test_emission_cadence_profile.py @@ -2,6 +2,7 @@ from __future__ import annotations import copy import importlib.util +import json import pathlib import sys @@ -92,14 +93,86 @@ def test_valid_rare_load_bearing_requires_both_positive_controls() -> None: result = report(declaration(rare_entry()), rare={"audit.deny"}) assert result["contract_valid"] is True + assert result["profile_assessed"] is True + assert result["inventory"]["rare_load_bearing"] == ["audit.deny"] assert result["conformant"] is True assert result["findings"] == [] +def test_omitted_inventory_does_not_claim_profile_conformance() -> None: + item = rare_entry() + del item["heartbeat"] + result = report(declaration(item)) + + assert result["contract_valid"] is True + assert result["profile_assessed"] is False + assert result["conformant"] is None + assert result["assessment_scope"] == "inventory-missing" + assert result["findings"] == [] # Rarity is never inferred from the entry. + + +@pytest.mark.parametrize( + "options,valid,exit_code,scope", + [ + ([], True, 2, "inventory-missing"), + (["--schema-only"], True, 0, "schema-only"), + (["--schema-only"], False, 1, "schema-only"), + ([], False, 1, "inventory-missing"), + ], +) +def test_cli_unassessed_results(tmp_path, capsys, options, valid, exit_code, scope): + schema = tmp_path / "schema.yaml" + document = tmp_path / "declaration.yaml" + schema.write_text(yaml.safe_dump(CONTRACT_SCHEMA)) + item = rare_entry() + del item["heartbeat"] + document.write_text(yaml.safe_dump(declaration(item) if valid else {})) + + assert ( + profile.main([str(document), "--contract-schema", str(schema), *options]) + == exit_code + ) + result = json.loads(capsys.readouterr().out) + assert result["contract_valid"] is valid + assert result["profile_assessed"] is False + assert result["conformant"] is None + assert result["assessment_scope"] == scope + + +@pytest.mark.parametrize( + "options", + [ + ["--load-bearing", "audit.deny"], + ["--rare-load-bearing", "audit.deny"], + ["--attributive", "audit.allow"], + ["--fail-on-should"], + ], +) +def test_schema_only_refuses_profile_options(options): + with pytest.raises(SystemExit) as exc: + profile.parse_args( + ["source.yaml", "--contract-schema", "schema.yaml", "--schema-only", *options] + ) + assert exc.value.code == 2 + + +@pytest.mark.parametrize( + "option", ["--load-bearing", "--rare-load-bearing", "--attributive"] +) +def test_blank_class_is_not_an_inventory(option): + with pytest.raises(SystemExit) as exc: + profile.parse_args( + ["source.yaml", "--contract-schema", "schema.yaml", option, " "] + ) + assert exc.value.code == 2 + + def test_contract_validation_runs_before_profile() -> None: result = report({"source": "example"}, rare={"audit.deny"}) assert result["contract_valid"] is False + assert result["profile_assessed"] is False + assert result["conformant"] is None assert codes(result) == {"contract-validation-failed"} assert "load-bearing-cadence-missing" not in codes(result) @@ -254,8 +327,6 @@ def test_duplicate_source_ids_fail_even_with_distinct_event_classes(upstream_sch def test_should_policy_and_cli(tmp_path, capsys): schema = tmp_path / "schema.json" document = tmp_path / "declaration.yaml" - import json - schema.write_text(json.dumps(CONTRACT_SCHEMA)) document.write_text(json.dumps(declaration())) args = [ diff --git a/workplans/NK-WP-0035-emission-cadence-security-profile.md b/workplans/NK-WP-0035-emission-cadence-security-profile.md index 69c384e..918f7be 100644 --- a/workplans/NK-WP-0035-emission-cadence-security-profile.md +++ b/workplans/NK-WP-0035-emission-cadence-security-profile.md @@ -10,7 +10,7 @@ owner: codex topic_slug: netkingdom planning_priority: P1 created: "2026-09-04" -updated: "2026-09-07" +updated: "2026-09-28" related: - GH-DEC-2026-004 - canon/standards/security-layer-model_v0.7.md @@ -122,6 +122,22 @@ checkout. Full reconciliation remains pending because API queries/writes timed out or returned connection-refused errors. Generated index/intake metadata was reviewed; the source files remain authoritative. +### Import metadata reconciled — 2026-09-28 + +Updated the importing profile to candidate document 0.2.0 / wire schema 0.1, +reviewed contract revision `4d0851c3fca306538b53838421f4499baf352778`, and +owner-published candidate bundle digest `b08b4d95fc4b0bd3`. Verified the schema +SHA-256 remains `6d52692eb1e4d1325e0d6062d95acded98beddaae5bcfe24c6f0e91f1b6be6ae`. +Corrected local-identity's current declaration pin and preserved the old pin +with its correction in the historical findings. No emission behavior changed. + +All 15 focused cadence tests pass. Revalidation with the two source-owned +rare-class assertions is generic-valid and still returns exactly two +`rare-heartbeat-missing` findings. T04 remains `wait` for external source +migration, observer integration and the explicit local-identity incompatibility +resolution; the profile remains proposed. Metadata repair is complete and does +not count as source/observer adoption. + ## Review the layer model's use of the profile ```task @@ -153,3 +169,31 @@ profile, carries the volume/rare split explicitly, and states that classification is the source's to publish and never the checker's to infer. Change log item 6 and §14 record the review; the standard remains `proposed` and publication waits on the close of the circulation round. + +## Infrastructure review — 2026-09-28 + +T04 remains `wait`, but upstream publication is no longer the blocker. +The current generic document is candidate 0.2.0 with wire schema 0.1; +InfoTechCanon corrected the candidate bundle digest to `b08b4d95fc4b0bd3` +(the wire schema was unchanged). Review and update the profile's old +draft/document/revision description and local-identity's stale bundle pin +against the exact owner artifact before handoff; do not confuse a bundle digest +with the schema SHA-256. + +Approval Engine and Qonto still carry draft-shaped owner envelopes. In addition, +NetKingdom now has its own source declaration at +`local-identity/emission-cadence.yaml`: generic validation passed, but both +rare load-bearing entries lack the required heartbeat. The source has no +audit-core sender/feed; its findings also record dropped audit I/O errors and +no completeness claim. Generic validity is not security-profile conformance +or an operating observer result. + +Extend T04 acceptance to cover the local source explicitly: either implement +and evidence activity-scoped heartbeat/reconciliation with its owners, or +retain the documented incompatibility without claiming profile-wide adoption. +Resolve the declared heartbeat event class versus audit-core's registered +`heartbeat_classes` mapping, migrate the two external owner instances, and +obtain a real source/observer result before the King's Guard handoff. Keep +the profile proposed; no weakened rare-class conjunction is approved. + +Evidence and cross-plan priorities: [estate review](../history/2026-09-28-open-workplan-infrastructure-review.md).