From cfc9e7d0cb71948c032b07e916efe2d9f31db56e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 23 Aug 2026 13:16:34 +0200 Subject: [PATCH] feat(posture): add deterministic feedback proposals Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02929-244b-7391-b933-c04010e8eedb --- Makefile | 13 +- README.md | 2 + SCOPE.md | 26 +- .../posture-feedback-report_v0.1.schema.json | 145 ++++++ .../schemas/tenancy-posture_v0.1.schema.json | 92 ++++ canon/standards/posture-feedback_v0.1.md | 109 +++++ canon/standards/tenancy-posture_v0.1.md | 28 +- examples/posture-feedback/README.md | 16 + examples/posture-feedback/expired-e2.yaml | 60 +++ ...-08-23-posture-feedback-estate-baseline.md | 47 ++ .../2026-08-23-scope-intent-gap-assessment.md | 10 + .../capability.security.iam-tooling-suite.md | 8 +- registry/indexes/capabilities.yaml | 2 +- tools/posture-feedback/README.md | 22 + tools/posture-feedback/posture_feedback.py | 440 ++++++++++++++++++ .../tests/test_posture_feedback.py | 261 +++++++++++ tools/tenancy-posture/test_validate.py | 35 ++ tools/tenancy-posture/validate.py | 17 + ...-WP-0031-deterministic-posture-feedback.md | 111 +++++ 19 files changed, 1428 insertions(+), 16 deletions(-) create mode 100644 canon/schemas/posture-feedback-report_v0.1.schema.json create mode 100644 canon/standards/posture-feedback_v0.1.md create mode 100644 examples/posture-feedback/README.md create mode 100644 examples/posture-feedback/expired-e2.yaml create mode 100644 history/2026-08-23-posture-feedback-estate-baseline.md create mode 100644 tools/posture-feedback/README.md create mode 100644 tools/posture-feedback/posture_feedback.py create mode 100644 tools/posture-feedback/tests/test_posture_feedback.py create mode 100644 workplans/NK-WP-0031-deterministic-posture-feedback.md diff --git a/Makefile b/Makefile index 20ea18c..94e4bc3 100644 --- a/Makefile +++ b/Makefile @@ -199,6 +199,16 @@ security-scenario-compose-c0: ## Compose the plan-only C0 local-identity referen --scenario examples/security-scenarios/c0-local-identity.yaml \ capabilities/playbooks/net-kingdom.local-identity.yaml +posture-feedback-test: ## Run deterministic posture-feedback tests + python3 -m pytest tools/posture-feedback/tests tools/tenancy-posture/test_validate.py + +posture-feedback-reference: ## Emit the proposal-only reference report at its fixed example time + python3 tools/posture-feedback/posture_feedback.py \ + --as-of 2026-08-23T12:00:00Z \ + --horizon-days 14 \ + --fail-on none \ + examples/posture-feedback/expired-e2.yaml + security-bootstrap-console-test: ## Run automated tests for bootstrap console UI/sections/runbooks (NET-WP-0018-T07) python3 -m pytest tools/security-bootstrap-console/tests @@ -358,7 +368,8 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a creds-status creds-rotate \ creds-agent-init creds-agent-status creds-emergency-reprint \ openbao-init-unseal openbao-init-unseal-dry-run \ - iam-profile-conformance-test playbook-contract-test \ + iam-profile-conformance-test playbook-contract-test posture-feedback-test \ + posture-feedback-reference \ security-bootstrap-console-test security-bootstrap-scripts-syntax \ security-bootstrap-console security-bootstrap-king-kit \ security-bootstrap-validate-kit security-bootstrap-validate-t02 \ diff --git a/README.md b/README.md index 22dcb5b..eeeb71b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ The dynamic, self-optimizing security platform is the long-term direction in - [SCOPE.md](SCOPE.md) — what this repo owns, current state, and when it is relevant - [Security scenario composition](canon/standards/security-scenario-composition_v0.1.md) — deterministic, plan-only capability and trust composition +- [Posture feedback](canon/standards/posture-feedback_v0.1.md) — deterministic, + proposal-only posture and evidence remediation findings ## Security Infrastructure Documents diff --git a/SCOPE.md b/SCOPE.md index 378e26c..c0099d6 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -44,6 +44,8 @@ about the current implementation. selection/parameterization and Railiance execution. - Security Scenario Composition v0.1 for deterministic provider selection, trust ordering, responsibility mapping, and non-executing owner handoff. +- Posture Feedback v0.1 for deterministic, proposal-only routing of posture + review, evidence-freshness, implementation, and declared-gap findings. - Tenancy Posture v0.1 and Security Zones v0.1 proposed standards, their schemas, validators, evidence rules, and publication stewardship. Zone semantics are owned by `zone-engine`; NetKingdom owns their canon publication. @@ -54,11 +56,13 @@ about the current implementation. - `local-identity/`: minimal file-backed OIDC identity for bootstrap, development, test, and sandbox use. -- IAM Profile, playbook-capability, tenancy-posture, custody, evidence, and - bootstrap-policy validators. +- IAM Profile, playbook-capability, tenancy-posture, posture-feedback, custody, + evidence, and bootstrap-policy validators. - `tools/security-scenario-composer/`: fail-closed, plan-only composition of declared capabilities, parameters, trust transitions, responsibilities, and readiness obligations. +- `tools/posture-feedback/`: deterministic, time-explicit remediation proposals + with authoritative owner resolution and no external or policy writes. - `tools/security-bootstrap-console/`: guarded platform-root and OpenBao bootstrap workflow, including refusal of unsafe live initialization. - SOPS/age bootstrap integration, credential-generation and rotation helpers, @@ -110,14 +114,16 @@ repositories while NetKingdom retains the contracts and reference evidence. | C3 — Runtime secrets | OpenBao custody, bootstrap, policy, delivery, and recovery contracts | Integrated with an externally deployed runtime; production evidence remains gated | | C4 — Fine-grained authorization | flex-auth caller identity and boundary integration | Partially delivered; full estate/PDP readiness is not established here | | C5 — Enterprise federation | Keycloak/SAML/enterprise-IdP design | Backlog; not a current provided runtime capability | -| C6 — Self-optimizing security | Declarations, validators, evidence freshness, workplans, and drift surfacing | Early governance mechanisms only; no autonomous closed loop | +| C6 — Self-optimizing security | Declarations, validators, evidence freshness, and deterministic owner-routed remediation proposals | First proposal-only feedback loop delivered; no autonomous policy mutation or closed loop | Current open work as of 2026-08-23 is either externally blocked, date-gated, or explicit backlog: reef carrier/public-classification decisions in NK-WP-0027, the NK-WP-0022 retirement gate, security tutorials in NK-WP-0009, and enterprise federation in NK-WP-0011. NK-WP-0030 has delivered the local C0 scenario-composition slice and waits for externally owned C1/C2 provider -declarations before the lightweight SSO scenario can compose. +declarations before the lightweight SSO scenario can compose. NK-WP-0031 has +delivered the local proposal-only feedback evaluator and waits for authoritative +freshness adoption by `audit-core`. --- @@ -126,8 +132,8 @@ declarations before the lightweight SSO scenario can compose. - Defining or reviewing identity, tenancy, workload-zone, credential, and security-composition canon. - Bootstrapping identity and trust before the normal platform is available. -- Validating an IAM issuer, posture declaration, or Railiance capability - declaration against NetKingdom contracts. +- Validating an IAM issuer, posture declaration, posture feedback report, or + Railiance capability declaration against NetKingdom contracts. - Integrating KeyCape, flex-auth, OpenBao, user-engine, tenant-engine, or a Railiance package across an explicit security boundary. - Deciding which repository owns a security semantic, runtime, deployment, or @@ -151,15 +157,15 @@ declarations before the lightweight SSO scenario can compose. ```capability type: governance title: NetKingdom security canon -description: Provider-neutral IAM v0.3, user/tenant boundaries, credential, playbook and scenario composition, tenancy-posture, and workload-zone standards with explicit ownership and conformance rules. +description: Provider-neutral IAM v0.3, user/tenant boundaries, credential, playbook and scenario composition, tenancy-posture, posture-feedback, and workload-zone standards with explicit ownership and conformance rules. keywords: [iam, oidc, tenancy, workload-identity, security-zones, credentials, canon] ``` ```capability type: validation title: Security contract conformance -description: Executable validation for IAM Profile issuers, playbook capability declarations, tenancy posture, bootstrap custody, and non-secret evidence records. -keywords: [validation, conformance, iam, posture, evidence, playbooks] +description: Executable validation for IAM Profile issuers, playbook capability declarations, tenancy posture, deterministic proposal-only feedback, bootstrap custody, and non-secret evidence records. +keywords: [validation, conformance, iam, posture, feedback, evidence, playbooks] ``` ```capability @@ -192,5 +198,5 @@ keywords: [meta-orchestration, railiance, responsibility, capability, trust-stat `tools/security-bootstrap-console/` - Executable surfaces: `local-identity/`, `tools/iam-profile-conformance/`, `tools/playbook-capability-contract/`, `tools/security-scenario-composer/`, - and `tools/tenancy-posture/` + `tools/tenancy-posture/`, and `tools/posture-feedback/` - Work state: `.custodian-brief.md` and `workplans/` diff --git a/canon/schemas/posture-feedback-report_v0.1.schema.json b/canon/schemas/posture-feedback-report_v0.1.schema.json new file mode 100644 index 0000000..7cb7078 --- /dev/null +++ b/canon/schemas/posture-feedback-report_v0.1.schema.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://netkingdom.local/schemas/posture-feedback-report_v0.1.schema.json", + "title": "NetKingdom Posture Feedback Report v0.1", + "type": "object", + "additionalProperties": false, + "required": [ + "apiVersion", + "kind", + "ok", + "as_of", + "horizon_days", + "fail_on", + "automation", + "summary", + "validation_errors", + "findings", + "report_digest" + ], + "properties": { + "apiVersion": { + "const": "netkingdom.io/posture-feedback/v0.1" + }, + "kind": { + "const": "PostureFeedbackReport" + }, + "ok": { + "type": "boolean" + }, + "as_of": { + "type": "string", + "format": "date-time" + }, + "horizon_days": { + "type": "integer", + "minimum": 0 + }, + "fail_on": { + "enum": ["none", "low", "medium", "high"] + }, + "automation": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "external_write_permitted", + "policy_mutation_permitted", + "declaration_mutation_permitted" + ], + "properties": { + "mode": {"const": "proposal-only"}, + "external_write_permitted": {"const": false}, + "policy_mutation_permitted": {"const": false}, + "declaration_mutation_permitted": {"const": false} + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["total", "high", "medium", "low", "unknown_owner"], + "properties": { + "total": {"type": "integer", "minimum": 0}, + "high": {"type": "integer", "minimum": 0}, + "medium": {"type": "integer", "minimum": 0}, + "low": {"type": "integer", "minimum": 0}, + "unknown_owner": {"type": "integer", "minimum": 0} + } + }, + "validation_errors": { + "type": "array", + "items": {"type": "string", "minLength": 1} + }, + "findings": { + "type": "array", + "items": { + "$ref": "#/$defs/finding" + } + }, + "report_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "$defs": { + "finding": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "class", + "severity", + "source", + "service", + "control", + "owner", + "evidence_state", + "reason", + "recommended_action" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^NKFB-[0-9a-f]{16}$" + }, + "class": { + "enum": [ + "posture-review-overdue", + "posture-review-due-soon", + "zone-review-overdue", + "zone-review-due-soon", + "evidence-freshness-unknown", + "evidence-expired", + "evidence-due-soon", + "implemented-not-evidenced", + "declared-gap" + ] + }, + "severity": { + "enum": ["low", "medium", "high"] + }, + "source": {"type": "string", "minLength": 1}, + "service": {"type": "string", "minLength": 1}, + "control": {"type": "string", "minLength": 1}, + "owner": {"type": "string", "minLength": 1}, + "evidence_state": { + "enum": [ + "not-applicable", + "due-soon", + "overdue", + "unknown", + "expired", + "implemented", + "gap" + ] + }, + "due": { + "type": "string", + "minLength": 1 + }, + "reason": {"type": "string", "minLength": 1}, + "recommended_action": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/canon/schemas/tenancy-posture_v0.1.schema.json b/canon/schemas/tenancy-posture_v0.1.schema.json index bf45787..17351db 100644 --- a/canon/schemas/tenancy-posture_v0.1.schema.json +++ b/canon/schemas/tenancy-posture_v0.1.schema.json @@ -21,6 +21,9 @@ "type": "string", "minLength": 1 }, + "responsible_repo": { + "$ref": "#/$defs/repoName" + }, "workload_identity": { "$ref": "#/$defs/workloadIdentity" }, @@ -36,6 +39,9 @@ "evidence": { "$ref": "#/$defs/evidence" }, + "evidence_freshness": { + "$ref": "#/$defs/evidenceFreshness" + }, "notes": { "$ref": "#/$defs/stringList" }, @@ -100,6 +106,16 @@ "required": [ "evidence" ] + }, + { + "required": [ + "responsible_repo" + ] + }, + { + "required": [ + "evidence_freshness" + ] } ] } @@ -601,6 +617,76 @@ ] } }, + "evidenceFreshnessEntry": { + "type": "object", + "required": [ + "kind", + "observed_at", + "responsible_repo", + "scope", + "remediation" + ], + "properties": { + "kind": { + "enum": [ + "mechanical", + "adversarial" + ] + }, + "observed_at": { + "type": "string", + "format": "date-time" + }, + "valid_until": { + "type": "string", + "format": "date-time" + }, + "responsible_repo": { + "$ref": "#/$defs/repoName" + }, + "scope": { + "type": "string", + "minLength": 1 + }, + "remediation": { + "type": "string", + "minLength": 1 + }, + "source_revision": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "adversarial" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "required": [ + "valid_until" + ] + } + } + ], + "additionalProperties": false + }, + "evidenceFreshness": { + "type": "object", + "patternProperties": { + "^[IAEPRV][0-4]$": { + "$ref": "#/$defs/evidenceFreshnessEntry" + } + }, + "additionalProperties": false + }, "service": { "type": "object", "required": [ @@ -616,6 +702,9 @@ "type": "string", "minLength": 1 }, + "responsible_repo": { + "$ref": "#/$defs/repoName" + }, "workload_identity": { "$ref": "#/$defs/workloadIdentity" }, @@ -628,6 +717,9 @@ "evidence": { "$ref": "#/$defs/evidence" }, + "evidence_freshness": { + "$ref": "#/$defs/evidenceFreshness" + }, "notes": { "$ref": "#/$defs/stringList" }, diff --git a/canon/standards/posture-feedback_v0.1.md b/canon/standards/posture-feedback_v0.1.md new file mode 100644 index 0000000..0b72654 --- /dev/null +++ b/canon/standards/posture-feedback_v0.1.md @@ -0,0 +1,109 @@ +--- +id: netkingdom-posture-feedback-v0.1 +type: standard +title: "NetKingdom Posture Feedback v0.1" +domain: netkingdom +status: proposed +version: "0.1" +owner: net-kingdom +created: "2026-08-23" +updated: "2026-08-23" +last_reviewed: "2026-08-23" +review_interval: 3m +scope: self-optimizing-security +schema: + - canon/schemas/posture-feedback-report_v0.1.schema.json +validator: + - tools/posture-feedback/posture_feedback.py +related: + - canon/standards/tenancy-posture_v0.1.md + - canon/standards/security-zones_v0.1.md +--- + +# NetKingdom Posture Feedback v0.1 + +## 1. Purpose + +This contract is the first bounded C6 feedback mechanism. It turns explicit +posture review dates, evidence freshness, implemented-but-unevidenced controls, +and declared gaps into deterministic remediation **proposals**. + +It does not modify a posture level, policy, declaration, workplan, State Hub, +or runtime. Human or separately governed automation decides whether a proposal +becomes work. + +## 2. Deterministic time + +Evaluation requires an explicit RFC 3339 `as_of` timestamp. Wall-clock time is +never read implicitly. A date-only `review_due` remains current through that +calendar date in UTC and becomes overdue on the following UTC date. + +A non-negative horizon in days identifies items due soon. Changing the horizon +changes the report digest and is therefore visible. + +## 3. Owner resolution + +The evaluator routes only from authoritative declaration fields: + +- posture review, gaps, and implemented controls: `responsible_repo`; +- evidence replacement: `evidence_freshness..responsible_repo`; +- security-zone review: `zones.responsible_party`. + +If the relevant field is absent, owner resolution is `unknown`. The evaluator +must not infer ownership from the service name, repository path, Git remote, +previous work, or another policy subject. + +## 4. Finding classes + +| Finding | Trigger | Severity | +| --- | --- | --- | +| `posture-review-overdue` | `as_of` is after `tenancy.review_due` | high | +| `posture-review-due-soon` | review is within the horizon | medium | +| `zone-review-overdue` | `as_of` is after `zones.review_due` | high | +| `zone-review-due-soon` | zone review is within the horizon | medium | +| `evidence-freshness-unknown` | a current adversarial level has no complete freshness entry | high | +| `evidence-expired` | `as_of` is after `valid_until` | high | +| `evidence-due-soon` | evidence expires within the horizon | medium | +| `implemented-not-evidenced` | an `implemented` level is above `current` | medium | +| `declared-gap` | a non-empty `tenancy.gap` entry exists | low | + +The review horizon does not generate a due-soon finding for an item already +overdue or expired. Exact equality with a timestamp is still valid; expiry is +strictly `as_of > valid_until`. + +Current adversarial levels are `E2`, `R4`, and `V2`–`V4`. This vocabulary comes +from Tenancy Posture §13. Mechanical evidence is evaluated for expiry only when +its declaration explicitly supplies `valid_until`. + +## 5. Proposal and safety boundary + +Every finding receives a stable id derived from its source declaration, +service, finding class, control, and due value. It contains the authoritative +owner or `unknown`, current evidence state, reason, and recommended action. +For declarations under the workspace containing this repository, the source is +normalized to `/` so absolute checkout locations do not perturb the +identity. This source normalization identifies an input only; it is never an +ownership inference. + +Every report declares: + +```yaml +automation: + mode: proposal-only + external_write_permitted: false + policy_mutation_permitted: false + declaration_mutation_permitted: false +``` + +Expired or unknown evidence does not silently inherit freshness and does not +silently downgrade a level. It makes the uncertainty visible for governed +review. Consumers that use the report as an admission gate may fail closed on +high findings, but that is a separate owner decision. + +## 6. Exit behavior + +The CLI emits a report conforming to +`posture-feedback-report_v0.1.schema.json`. `--fail-on high` exits non-zero when +at least one high-severity finding exists; `medium` includes medium and high; +`low` includes every finding; `none` reports without a finding-based failure. +Invalid declarations always exit non-zero. diff --git a/canon/standards/tenancy-posture_v0.1.md b/canon/standards/tenancy-posture_v0.1.md index 916b748..c217e21 100644 --- a/canon/standards/tenancy-posture_v0.1.md +++ b/canon/standards/tenancy-posture_v0.1.md @@ -8,7 +8,7 @@ version: "0.1" created: "2026-08-17" updated: "2026-08-23" scope: multi-tenancy-security-framework -revision: "draft-13" +revision: "draft-14" owner: net-kingdom last_reviewed: "2026-08-23" review_interval: 6m @@ -87,6 +87,11 @@ and the tenant-engine boundary contract, not in the work-factory canon. required by §13.2. The claim remains explicitly bounded and freshness-dated: the attempted cross-tenant attacks did not work; this is not a universal isolation proof. +- **draft-14** makes evidence freshness and remediation ownership declarable. + Adversarial evidence may now carry its observation and expiry timestamps, + bounded scope, responsible repository, and replacement action. A separate + proposal-only evaluator treats absent authoritative owner or freshness as + `unknown`; it does not infer either or mutate the declared posture. **Reviewed by all six. The score:** six repos found three live defects in their own code by reading the ladders — `tenant-engine`'s unfiltered @@ -826,6 +831,11 @@ declaration but do not replace it. The schema carries `current`, `implemented`, `target`, `reviewed`, `review_due`, `gap`, `placement_exceptions`, `service_class` (§8.3), per-path detail (§5.2), and provider reachability (§5.5), plus the workload identity prerequisite for zone membership (§5.6.2). +It also permits `responsible_repo` for authoritative posture routing and +`evidence_freshness` for machine-readable evidence observation, expiry, scope, +owner, and remediation metadata. Their absence remains valid declaration +syntax; feedback resolution must report `unknown`, never infer them from a +directory, service name, or previous owner. From the `net-kingdom` repo, owners validate one or more declarations with `uv run tools/tenancy-posture/validate.py ...`; the validator applies the JSON Schema and the evidence, date, implemented/current and provider-range @@ -1129,6 +1139,22 @@ rather than a green build. Cross-tenant findings are the category external testing practice identifies as needing human review. **A passing CI run is not E2 evidence.** +**Decision 13.5 — freshness and ownership are explicit inputs.** A declaration +may attach `evidence_freshness.` to an evidence key. An adversarial entry +requires `observed_at`, `valid_until`, `responsible_repo`, `scope`, and +`remediation`; a mechanical entry may omit `valid_until` when the artifact is +continuously re-established by the referenced revision or CI control. The +timestamps use RFC 3339 and the responsible repository is the authority for +replacement evidence. + +The feedback evaluator does not parse prose for dates, infer ownership from a +file path, or silently extend a validity window. A current adversarial claim +without freshness metadata resolves to **freshness `unknown`**. An expired +artifact resolves to **freshness `expired`**. Neither automatically rewrites the +declared level: the evaluator emits a deterministic owner-routed remediation +proposal so review remains observable and controlled. The proposal contract is +`posture-feedback_v0.1`; it performs no State Hub write or policy mutation. + | Level | Evidence | Kind | |---|---|---| | **I2** | Identifiers validated against the vocabulary; rejection test for a malformed id; binding shown to come from a verified token | Mechanical | diff --git a/examples/posture-feedback/README.md b/examples/posture-feedback/README.md new file mode 100644 index 0000000..a5bf41a --- /dev/null +++ b/examples/posture-feedback/README.md @@ -0,0 +1,16 @@ +# Posture Feedback Example + +`expired-e2.yaml` is deliberately valid as a posture declaration while carrying +expired E2 evidence at the example evaluation time. It also has posture and +zone reviews due within 14 days, an E3 control awaiting evidence, and one +declared retention gap. + +The evaluator reports these as proposals and performs no mutation: + +```bash +uv run tools/posture-feedback/posture_feedback.py \ + --as-of 2026-08-23T12:00:00Z \ + --horizon-days 14 \ + --fail-on none \ + examples/posture-feedback/expired-e2.yaml +``` diff --git a/examples/posture-feedback/expired-e2.yaml b/examples/posture-feedback/expired-e2.yaml new file mode 100644 index 0000000..1980720 --- /dev/null +++ b/examples/posture-feedback/expired-e2.yaml @@ -0,0 +1,60 @@ +schema_version: "0.1" +framework: netkingdom-tenancy-posture +service: example-security-api +role: tenant-api +responsible_repo: net-kingdom + +workload_identity: + name: example-security-api + kind: application + responsible_repo: net-kingdom + identity_bindings: + - scheme: iam-profile + authority: key-cape + subject: example-security-api + principal_type: service + environment: reference + +tenancy: + current: {I: 1, A: 2, E: 2, P: 0, R: 1, V: 0} + implemented: {E: 3} + target: {I: 2, A: 3, E: 3, P: 1, R: 2, V: 1} + reviewed: "2026-08-01" + review_due: "2026-08-31" + service_class: interactive + reason: + I: Request identity is not verified in this reference fixture. + P: Reference fixture has no provisioned datastore. + R: Platform default retention applies. + V: No availability exercise exists. + gap: + R: Publish and evidence an explicit erasure horizon. + +zones: + standard: security-zones_v0.1 + membership: z1-operational + responsible_party: team:platform-security + justification: Reference workload with bounded internal data. + context: + maturity: M1 + criticality: medium + data_classification: internal + evidence: + - ref: docs/evidence/reference-zone.md + supports: [M1, reference] + reviewed: "2026-08-01" + review_due: "2026-08-25" + +evidence: + A2: docs/evidence/reference-authorization.md + E2: docs/evidence/reference-adversarial-e2.md + +evidence_freshness: + E2: + kind: adversarial + observed_at: "2026-08-20T12:00:00Z" + valid_until: "2026-08-22T12:00:00Z" + responsible_repo: net-kingdom + scope: Two bounded cross-tenant read probes against the reference fixture. + remediation: Repeat the bounded E2 probes and replace the evidence metadata. + source_revision: reference@0000000 diff --git a/history/2026-08-23-posture-feedback-estate-baseline.md b/history/2026-08-23-posture-feedback-estate-baseline.md new file mode 100644 index 0000000..40155c9 --- /dev/null +++ b/history/2026-08-23-posture-feedback-estate-baseline.md @@ -0,0 +1,47 @@ +# Posture feedback estate baseline + +Date: 2026-08-23 +Workplan: NK-WP-0031 +Mode: read-only, proposal-only + +## Evaluation + +The proposed Posture Feedback v0.1 evaluator was run with: + +- `as_of`: `2026-08-23T11:05:14Z` +- `horizon_days`: `30` +- `fail_on`: `none` +- inputs: the tenancy declarations in `adaptive-pricing`, `audit-core`, + `flex-auth`, `ops-warden`, `railiance-platform`, `rapp-postgres`, and + `tenant-engine` + +The report digest was +`sha256:66174f08cf5feb1e5618477a374975100a4712bcaa354fcb1b1702103482b6c1`. +It contained 34 proposals: one high, six medium, and 27 low. All 34 owners were +`unknown`, which is the required result because these declarations do not yet +carry the new authoritative `responsible_repo` fields. + +## High finding + +`NKFB-c08b78e82248bf27` reports `audit-core` E2 evidence freshness as +`unknown`. The declaration describes the Whitehat run and its 24-hour validity +in prose but does not yet supply the machine-readable `evidence_freshness.E2` +authority. NetKingdom did not infer timestamps, ownership, scope, or a +replacement action from that prose. + +Adoption was routed to `audit-core` in State Hub message +`874e5fa5-e05c-4b1a-a915-26c4dba07b87`. + +## Declaration validation errors + +The `railiance-platform` declaration did not enter evaluation because its +`apps-pg` service declares current R2 and V1 without the evidence entries +required for current non-zero levels. Remediation was routed to +`railiance-platform` in State Hub message +`d65f8383-bdab-488e-a36f-f8cabd8a5f65`. + +## Safety result + +The evaluator changed no declaration, policy, workplan, State Hub record, or +runtime. The two State Hub messages above were explicit operator-process +handoffs after inspection of the report; they were not emitted by the tool. diff --git a/history/2026-08-23-scope-intent-gap-assessment.md b/history/2026-08-23-scope-intent-gap-assessment.md index d191475..b6c46c1 100644 --- a/history/2026-08-23-scope-intent-gap-assessment.md +++ b/history/2026-08-23-scope-intent-gap-assessment.md @@ -145,3 +145,13 @@ deterministic provider pins, trust ordering, responsibility mapping, and readiness handoff without executing provider actions. G1 remains open for the lightweight SSO path until the KeyCape/Railiance owners publish conformant C1 and C2 declarations. + +NK-WP-0031 subsequently implemented the first bounded G2 increment: proposed +Posture Feedback v0.1 canon and a deterministic evaluator that turns explicit +review dates, evidence freshness, implemented-but-unevidenced controls, and +declared gaps into owner-routed remediation proposals. Time is an explicit +input; missing ownership or freshness resolves to `unknown`; and the report +forbids external, declaration, and policy writes. G2 remains open because the +tool does not create work, change policy, close remediation, or consume runtime +telemetry autonomously. Those powers require separate authority, rollback, and +review decisions. diff --git a/registry/capabilities/capability.security.iam-tooling-suite.md b/registry/capabilities/capability.security.iam-tooling-suite.md index d17eef1..2064c96 100644 --- a/registry/capabilities/capability.security.iam-tooling-suite.md +++ b/registry/capabilities/capability.security.iam-tooling-suite.md @@ -2,7 +2,7 @@ id: capability.security.iam-tooling-suite name: NetKingdom Security/IAM Tooling Suite summary: Canonical security architecture and executable reference tooling for IAM, bootstrap, posture validation, - and deterministic plan-only security scenario composition. + deterministic plan-only security scenario composition, and proposal-only posture feedback. owner: net-kingdom status: draft domain: infotech @@ -25,7 +25,7 @@ maturity: target: A3 confidence: medium rationale: 'No top-level package manifest, but tools/ holds independently documented and runnable - conformance, posture, scenario-composition, and guarded bootstrap tools; local-identity is separately + conformance, posture, posture-feedback, scenario-composition, and guarded bootstrap tools; local-identity is separately packaged and tested.' external_evidence: completeness: @@ -53,6 +53,7 @@ discovery: - playbook capability contract validator - deterministic plan-only security scenario composer - tenancy posture schema and validator + - deterministic proposal-only posture feedback evaluator - security bootstrap console (local, non-secret-collecting) - packaged local bootstrap identity CLI excludes: @@ -69,6 +70,7 @@ availability: - tools/playbook-capability-contract - tools/security-scenario-composer - tools/tenancy-posture + - tools/posture-feedback - tools/security-bootstrap-console - local-identity target_artifacts: [] @@ -105,7 +107,7 @@ promotion_history: [] `net-kingdom` provides canonical IAM and security architecture, executable conformance and posture checks, deterministic plan-only scenario composition, -a local bootstrap identity package, and a non-secret-collecting security +proposal-only posture feedback, a local bootstrap identity package, and a non-secret-collecting security bootstrap console. Provider implementations remain in sibling repositories such as `key-cape`; the self-optimizing platform remains an intent rather than a current delivery claim. diff --git a/registry/indexes/capabilities.yaml b/registry/indexes/capabilities.yaml index ae4c756..3a618f7 100644 --- a/registry/indexes/capabilities.yaml +++ b/registry/indexes/capabilities.yaml @@ -5,7 +5,7 @@ capabilities: - id: capability.security.iam-tooling-suite name: NetKingdom Security/IAM Tooling Suite summary: Canonical security architecture and executable reference tooling for IAM, bootstrap, posture - validation, and deterministic plan-only security scenario composition. + validation, deterministic plan-only security scenario composition, and proposal-only posture feedback. vector: D3 / A2 / C1 / R1 domain: infotech status: draft diff --git a/tools/posture-feedback/README.md b/tools/posture-feedback/README.md new file mode 100644 index 0000000..23e4474 --- /dev/null +++ b/tools/posture-feedback/README.md @@ -0,0 +1,22 @@ +# Posture Feedback Evaluator + +This tool implements the proposal-only feedback boundary in +`canon/standards/posture-feedback_v0.1.md`. It reads tenancy declarations and +emits deterministic JSON. It never writes State Hub, edits a declaration, +changes policy, or executes remediation. + +Evaluate the checked-in reference at an explicit time: + +```bash +uv run tools/posture-feedback/posture_feedback.py \ + --as-of 2026-08-23T12:00:00Z \ + --horizon-days 14 \ + --fail-on none \ + examples/posture-feedback/expired-e2.yaml +``` + +Run tests: + +```bash +uv run pytest tools/posture-feedback/tests +``` diff --git a/tools/posture-feedback/posture_feedback.py b/tools/posture-feedback/posture_feedback.py new file mode 100644 index 0000000..f7bdc89 --- /dev/null +++ b/tools/posture-feedback/posture_feedback.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["jsonschema>=4.23,<5", "PyYAML>=6,<7"] +# /// +"""Emit deterministic, proposal-only feedback for tenancy posture declarations.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import importlib.util +import json +import pathlib +import sys +from collections import Counter +from typing import Any + +import jsonschema +import yaml + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +TENANCY_SCHEMA = ROOT / "canon/schemas/tenancy-posture_v0.1.schema.json" +REPORT_SCHEMA = ROOT / "canon/schemas/posture-feedback-report_v0.1.schema.json" +TENANCY_VALIDATOR_PATH = ROOT / "tools/tenancy-posture/validate.py" +API_VERSION = "netkingdom.io/posture-feedback/v0.1" +KIND = "PostureFeedbackReport" +ADVERSARIAL_LEVELS = {"E2", "R4", "V2", "V3", "V4"} +SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3} + +SPEC = importlib.util.spec_from_file_location( + "tenancy_posture_validator_for_feedback", TENANCY_VALIDATOR_PATH +) +assert SPEC and SPEC.loader +TENANCY_VALIDATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(TENANCY_VALIDATOR) + + +def parse_timestamp(value: str) -> dt.datetime: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError("timestamp must include an explicit UTC offset") + return parsed.astimezone(dt.timezone.utc) + + +def format_timestamp(value: dt.datetime) -> str: + return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z") + + +def load_document(path: pathlib.Path) -> dict[str, Any]: + document = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("document must be a YAML object") + return document + + +def service_entries(document: dict[str, Any]) -> list[dict[str, Any]]: + if "services" in document: + return document["services"] + return [document] + + +def source_identifier(path: pathlib.Path) -> str: + """Return a portable declaration id without treating the path as ownership.""" + resolved = path.resolve() + try: + return resolved.relative_to(ROOT.parent).as_posix() + except ValueError: + return resolved.as_posix() + + +def finding_id( + source: str, + service: str, + finding_class: str, + control: str, + due: str | None, +) -> str: + identity = "\x1f".join((source, service, finding_class, control, due or "")) + digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16] + return f"NKFB-{digest}" + + +def finding( + *, + source: str, + service: str, + finding_class: str, + severity: str, + control: str, + owner: str, + evidence_state: str, + reason: str, + recommended_action: str, + due: str | None = None, +) -> dict[str, Any]: + result = { + "id": finding_id(source, service, finding_class, control, due), + "class": finding_class, + "severity": severity, + "source": source, + "service": service, + "control": control, + "owner": owner, + "evidence_state": evidence_state, + "reason": reason, + "recommended_action": recommended_action, + } + if due is not None: + result["due"] = due + return result + + +def review_finding( + *, + source: str, + service: str, + owner: str, + control: str, + due: dt.date, + as_of: dt.datetime, + horizon_days: int, + prefix: str, +) -> dict[str, Any] | None: + due_text = due.isoformat() + if as_of.date() > due: + return finding( + source=source, + service=service, + finding_class=f"{prefix}-review-overdue", + severity="high", + control=control, + owner=owner, + evidence_state="overdue", + due=due_text, + reason=f"{control} review was due on {due_text} and is overdue at {format_timestamp(as_of)}.", + recommended_action=f"Review {control}, update its declaration and evidence, and retain the review record.", + ) + days = (due - as_of.date()).days + if days <= horizon_days: + return finding( + source=source, + service=service, + finding_class=f"{prefix}-review-due-soon", + severity="medium", + control=control, + owner=owner, + evidence_state="due-soon", + due=due_text, + reason=f"{control} review is due in {days} day(s), within the {horizon_days}-day horizon.", + recommended_action=f"Schedule and evidence the {control} review before {due_text}.", + ) + return None + + +def current_evidence_keys(entry: dict[str, Any]) -> set[str]: + current = entry["tenancy"]["current"] + return { + f"{axis}{level}" + for axis, level in current.items() + if isinstance(level, int) + } + + +def evidence_findings( + *, + source: str, + entry: dict[str, Any], + as_of: dt.datetime, + horizon_days: int, +) -> list[dict[str, Any]]: + service = entry["service"] + freshness = entry.get("evidence_freshness", {}) + current_keys = current_evidence_keys(entry) + results: list[dict[str, Any]] = [] + + for key in sorted(ADVERSARIAL_LEVELS & current_keys): + if key not in freshness: + results.append( + finding( + source=source, + service=service, + finding_class="evidence-freshness-unknown", + severity="high", + control=key, + owner="unknown", + evidence_state="unknown", + reason=f"Current adversarial claim {key} has no authoritative evidence_freshness entry.", + recommended_action=( + f"Declare the {key} observation, expiry, bounded scope, responsible repository, " + "and replacement action; do not infer freshness from prose." + ), + ) + ) + + for key in sorted(current_keys & set(freshness)): + metadata = freshness[key] + valid_until_raw = metadata.get("valid_until") + if not valid_until_raw: + continue + valid_until = parse_timestamp(valid_until_raw) + owner = metadata["responsible_repo"] + if as_of > valid_until: + results.append( + finding( + source=source, + service=service, + finding_class="evidence-expired", + severity="high", + control=key, + owner=owner, + evidence_state="expired", + due=format_timestamp(valid_until), + reason=f"Evidence for {key} expired before {format_timestamp(as_of)}.", + recommended_action=metadata["remediation"], + ) + ) + continue + remaining = valid_until - as_of + if remaining <= dt.timedelta(days=horizon_days): + results.append( + finding( + source=source, + service=service, + finding_class="evidence-due-soon", + severity="medium", + control=key, + owner=owner, + evidence_state="due-soon", + due=format_timestamp(valid_until), + reason=( + f"Evidence for {key} expires within the {horizon_days}-day horizon " + f"at {format_timestamp(valid_until)}." + ), + recommended_action=metadata["remediation"], + ) + ) + return results + + +def evaluate_entry( + source: str, + entry: dict[str, Any], + as_of: dt.datetime, + horizon_days: int, +) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + service = entry["service"] + posture = entry["tenancy"] + posture_owner = entry.get("responsible_repo", "unknown") + + posture_review = review_finding( + source=source, + service=service, + owner=posture_owner, + control="tenancy-posture", + due=dt.date.fromisoformat(posture["review_due"]), + as_of=as_of, + horizon_days=horizon_days, + prefix="posture", + ) + if posture_review: + results.append(posture_review) + + zones = entry.get("zones") + if zones: + zone_review = review_finding( + source=source, + service=service, + owner=zones["responsible_party"], + control="security-zones", + due=dt.date.fromisoformat(zones["review_due"]), + as_of=as_of, + horizon_days=horizon_days, + prefix="zone", + ) + if zone_review: + results.append(zone_review) + + results.extend( + evidence_findings( + source=source, + entry=entry, + as_of=as_of, + horizon_days=horizon_days, + ) + ) + + current = posture["current"] + for axis, level in sorted(posture.get("implemented", {}).items()): + current_level = current[axis] + results.append( + finding( + source=source, + service=service, + finding_class="implemented-not-evidenced", + severity="medium", + control=f"{axis}{level}", + owner=posture_owner, + evidence_state="implemented", + reason=f"{axis}{level} is implemented while the current evidenced level is {axis}{current_level}.", + recommended_action=f"Produce the canon-required evidence for {axis}{level} or remove the implemented claim.", + ) + ) + + for axis, description in sorted(posture.get("gap", {}).items()): + results.append( + finding( + source=source, + service=service, + finding_class="declared-gap", + severity="low", + control=axis, + owner=posture_owner, + evidence_state="gap", + reason=description, + recommended_action=f"Review the declared {axis}-axis gap and retain, schedule, or resolve it explicitly.", + ) + ) + return results + + +def report_digest(report_without_digest: dict[str, Any]) -> str: + encoded = json.dumps( + report_without_digest, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +def build_report( + paths: list[pathlib.Path], + *, + as_of: dt.datetime, + horizon_days: int, + fail_on: str, +) -> dict[str, Any]: + tenancy_schema = json.loads(TENANCY_SCHEMA.read_text(encoding="utf-8")) + validation_errors: list[str] = [] + findings: list[dict[str, Any]] = [] + + for path in paths: + source = source_identifier(path) + try: + document = load_document(path) + except Exception as exc: + validation_errors.append(f"{path}: {exc}") + continue + errors = TENANCY_VALIDATOR.validate(path, tenancy_schema) + if errors: + validation_errors.extend(errors) + continue + for entry in service_entries(document): + findings.extend(evaluate_entry(source, entry, as_of, horizon_days)) + + findings.sort( + key=lambda item: ( + item["source"], + item["service"], + item["class"], + item["control"], + item.get("due", ""), + ) + ) + counts = Counter(item["severity"] for item in findings) + threshold_failed = False + if fail_on != "none": + threshold = SEVERITY_RANK[fail_on] + threshold_failed = any( + SEVERITY_RANK[item["severity"]] >= threshold for item in findings + ) + + report: dict[str, Any] = { + "apiVersion": API_VERSION, + "kind": KIND, + "ok": not validation_errors and not threshold_failed, + "as_of": format_timestamp(as_of), + "horizon_days": horizon_days, + "fail_on": fail_on, + "automation": { + "mode": "proposal-only", + "external_write_permitted": False, + "policy_mutation_permitted": False, + "declaration_mutation_permitted": False, + }, + "summary": { + "total": len(findings), + "high": counts["high"], + "medium": counts["medium"], + "low": counts["low"], + "unknown_owner": sum(item["owner"] == "unknown" for item in findings), + }, + "validation_errors": sorted(validation_errors), + "findings": findings, + } + report["report_digest"] = report_digest(report) + report_schema = json.loads(REPORT_SCHEMA.read_text(encoding="utf-8")) + jsonschema.Draft202012Validator( + report_schema, + format_checker=jsonschema.FormatChecker(), + ).validate(report) + return report + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Emit deterministic, proposal-only NetKingdom posture feedback." + ) + parser.add_argument("declarations", nargs="+", type=pathlib.Path) + parser.add_argument("--as-of", required=True, help="RFC 3339 timestamp with explicit offset") + parser.add_argument("--horizon-days", type=int, default=30) + parser.add_argument("--fail-on", choices=("none", "low", "medium", "high"), default="high") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.horizon_days < 0: + raise SystemExit("--horizon-days must be non-negative") + try: + as_of = parse_timestamp(args.as_of) + except ValueError as exc: + raise SystemExit(f"invalid --as-of: {exc}") from exc + + report = build_report( + args.declarations, + as_of=as_of, + horizon_days=args.horizon_days, + fail_on=args.fail_on, + ) + print(json.dumps(report, indent=2, sort_keys=True)) + if report["validation_errors"]: + return 2 + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/posture-feedback/tests/test_posture_feedback.py b/tools/posture-feedback/tests/test_posture_feedback.py new file mode 100644 index 0000000..78bd7c5 --- /dev/null +++ b/tools/posture-feedback/tests/test_posture_feedback.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import copy +import importlib.util +import json +import pathlib +import sys + +import yaml + + +TOOL_PATH = pathlib.Path(__file__).resolve().parents[1] / "posture_feedback.py" +SPEC = importlib.util.spec_from_file_location("posture_feedback", TOOL_PATH) +feedback = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.modules[SPEC.name] = feedback +SPEC.loader.exec_module(feedback) + + +def declaration(service="example") -> dict: + return { + "schema_version": "0.1", + "framework": "netkingdom-tenancy-posture", + "service": service, + "role": "test-service", + "responsible_repo": "example-owner", + "tenancy": { + "current": {"I": 1, "A": 1, "E": 0, "P": 0, "R": 1, "V": 0}, + "target": {"I": 1, "A": 1, "E": 0, "P": 0, "R": 1, "V": 0}, + "reviewed": "2026-08-01", + "review_due": "2026-12-31", + "service_class": "interactive", + "reason": { + "I": "floor explained", + "A": "floor explained", + "E": "floor explained", + "P": "floor explained", + "R": "floor explained", + "V": "floor explained", + }, + }, + } + + +def write_declaration(tmp_path: pathlib.Path, document: dict, name="tenancy.yaml") -> pathlib.Path: + path = tmp_path / name + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + return path + + +def build(path: pathlib.Path, *, as_of="2026-08-23T12:00:00Z", horizon=14, fail_on="none"): + return feedback.build_report( + [path], + as_of=feedback.parse_timestamp(as_of), + horizon_days=horizon, + fail_on=fail_on, + ) + + +def classes(report: dict) -> list[str]: + return [item["class"] for item in report["findings"]] + + +def test_checked_in_reference_emits_five_proposals_and_no_authority(): + repo_root = pathlib.Path(__file__).resolve().parents[3] + path = repo_root / "examples/posture-feedback/expired-e2.yaml" + + report = build(path) + + assert report["summary"] == { + "total": 5, + "high": 1, + "medium": 3, + "low": 1, + "unknown_owner": 0, + } + assert report["automation"] == { + "mode": "proposal-only", + "external_write_permitted": False, + "policy_mutation_permitted": False, + "declaration_mutation_permitted": False, + } + + +def test_missing_adversarial_freshness_is_unknown_without_owner_inference(tmp_path): + document = declaration() + document["tenancy"]["current"]["E"] = 2 + document["tenancy"]["target"]["E"] = 2 + document["tenancy"]["reason"].pop("E") + document["evidence"] = {"E2": "docs/evidence/e2.md"} + path = write_declaration(tmp_path, document) + + report = build(path) + + finding = next(item for item in report["findings"] if item["class"] == "evidence-freshness-unknown") + assert finding["owner"] == "unknown" + assert finding["evidence_state"] == "unknown" + + +def test_exact_evidence_expiry_is_still_valid_but_due(tmp_path): + document = declaration() + document["tenancy"]["current"]["E"] = 2 + document["tenancy"]["target"]["E"] = 2 + document["tenancy"]["reason"].pop("E") + document["evidence"] = {"E2": "docs/evidence/e2.md"} + document["evidence_freshness"] = { + "E2": { + "kind": "adversarial", + "observed_at": "2026-08-22T12:00:00Z", + "valid_until": "2026-08-23T12:00:00Z", + "responsible_repo": "evidence-owner", + "scope": "bounded", + "remediation": "repeat", + } + } + path = write_declaration(tmp_path, document) + + report = build(path, horizon=0) + + assert "evidence-expired" not in classes(report) + assert "evidence-due-soon" in classes(report) + + +def test_review_due_date_expires_after_utc_calendar_day(tmp_path): + document = declaration() + document["tenancy"]["review_due"] = "2026-08-23" + path = write_declaration(tmp_path, document) + + due_today = build(path, as_of="2026-08-23T23:59:59Z", horizon=0) + overdue = build(path, as_of="2026-08-24T00:00:00Z", horizon=0) + + assert "posture-review-due-soon" in classes(due_today) + assert "posture-review-overdue" not in classes(due_today) + assert "posture-review-overdue" in classes(overdue) + + +def test_posture_owner_is_unknown_when_not_declared(tmp_path): + document = declaration() + del document["responsible_repo"] + document["tenancy"]["review_due"] = "2026-08-01" + path = write_declaration(tmp_path, document) + + report = build(path) + + finding = next(item for item in report["findings"] if item["class"] == "posture-review-overdue") + assert finding["owner"] == "unknown" + assert report["summary"]["unknown_owner"] == 1 + + +def test_multi_service_declaration_is_evaluated_per_service(tmp_path): + first = declaration("first") + second = declaration("second") + for entry in (first, second): + entry.pop("schema_version") + entry.pop("framework") + first["tenancy"]["gap"] = {"A": "first gap"} + second["tenancy"]["gap"] = {"V": "second gap"} + document = { + "schema_version": "0.1", + "framework": "netkingdom-tenancy-posture", + "services": [first, second], + } + path = write_declaration(tmp_path, document) + + report = build(path) + + assert {(item["service"], item["control"]) for item in report["findings"]} == { + ("first", "A"), + ("second", "V"), + } + + +def test_mechanical_evidence_without_expiry_creates_no_freshness_finding(tmp_path): + document = declaration() + document["tenancy"]["current"]["A"] = 2 + document["tenancy"]["target"]["A"] = 2 + document["tenancy"]["reason"].pop("A") + document["evidence"] = {"A2": "tests/authorization.py"} + document["evidence_freshness"] = { + "A2": { + "kind": "mechanical", + "observed_at": "2026-08-22T12:00:00Z", + "responsible_repo": "example-owner", + "scope": "continuous test", + "remediation": "repair the test", + } + } + path = write_declaration(tmp_path, document) + + report = build(path) + + assert not any(item["class"].startswith("evidence-") for item in report["findings"]) + + +def test_report_digest_is_stable_for_identical_inputs(tmp_path): + path = write_declaration(tmp_path, declaration()) + + first = build(path) + second = build(path) + + assert first == second + assert first["report_digest"].startswith("sha256:") + + +def test_workspace_source_identifier_is_portable_and_not_an_owner_inference(): + repo_root = pathlib.Path(__file__).resolve().parents[3] + path = repo_root / "examples/posture-feedback/expired-e2.yaml" + + report = build(path) + + assert {item["source"] for item in report["findings"]} == { + "net-kingdom/examples/posture-feedback/expired-e2.yaml" + } + gap = next(item for item in report["findings"] if item["class"] == "declared-gap") + zone = next(item for item in report["findings"] if item["class"].startswith("zone-")) + assert gap["owner"] == "net-kingdom" + assert zone["owner"] == "team:platform-security" + assert all(item["owner"] != item["source"] for item in report["findings"]) + + +def test_fail_on_threshold_changes_ok_not_findings(tmp_path): + document = declaration() + document["tenancy"]["gap"] = {"R": "declared gap"} + path = write_declaration(tmp_path, document) + + report_only = build(path, fail_on="none") + failing = build(path, fail_on="low") + + assert report_only["ok"] is True + assert failing["ok"] is False + assert report_only["findings"] == failing["findings"] + + +def test_invalid_declaration_is_reported_and_cli_exits_two(tmp_path, capsys): + document = declaration() + del document["role"] + path = write_declaration(tmp_path, document) + + exit_code = feedback.main( + [ + "--as-of", + "2026-08-23T12:00:00Z", + "--fail-on", + "none", + str(path), + ] + ) + payload = json.loads(capsys.readouterr().out) + + assert exit_code == 2 + assert payload["ok"] is False + assert payload["validation_errors"] + + +def test_timestamp_without_offset_is_rejected(): + try: + feedback.parse_timestamp("2026-08-23T12:00:00") + except ValueError as exc: + assert "explicit UTC offset" in str(exc) + else: + raise AssertionError("timezone-naive timestamp was accepted") diff --git a/tools/tenancy-posture/test_validate.py b/tools/tenancy-posture/test_validate.py index 6047324..041c572 100644 --- a/tools/tenancy-posture/test_validate.py +++ b/tools/tenancy-posture/test_validate.py @@ -89,6 +89,41 @@ class SemanticValidationTests(unittest.TestCase): document["tenancy"]["review_due"] = "2026-08-16" self.assertIn("review_due precedes reviewed", self.validate(document)[0]) + def test_evidence_freshness_must_reference_evidence_key(self) -> None: + document = declaration() + document["evidence_freshness"] = { + "E2": { + "kind": "adversarial", + "observed_at": "2026-08-22T22:10:25Z", + "valid_until": "2026-08-23T22:10:25Z", + "responsible_repo": "example", + "scope": "bounded tenant probes", + "remediation": "repeat the bounded run", + } + } + self.assertIn( + "evidence_freshness E2 has no evidence entry", + self.validate(document)[0], + ) + + def test_evidence_freshness_expiry_must_follow_observation(self) -> None: + document = declaration() + document["evidence"] = {"E2": "docs/evidence/e2.md"} + document["evidence_freshness"] = { + "E2": { + "kind": "adversarial", + "observed_at": "2026-08-22T22:10:25Z", + "valid_until": "2026-08-22T22:10:25Z", + "responsible_repo": "example", + "scope": "bounded tenant probes", + "remediation": "repeat the bounded run", + } + } + self.assertIn( + "valid_until must be after observed_at", + self.validate(document)[0], + ) + def test_service_names_are_unique(self) -> None: entry = declaration() document = { diff --git a/tools/tenancy-posture/validate.py b/tools/tenancy-posture/validate.py index 10b798c..94a0454 100644 --- a/tools/tenancy-posture/validate.py +++ b/tools/tenancy-posture/validate.py @@ -67,6 +67,7 @@ def validate_semantics(document: dict[str, Any], path: pathlib.Path) -> list[str reason = posture.get("reason", {}) gap = posture.get("gap", {}) evidence = entry.get("evidence", {}) + evidence_freshness = entry.get("evidence_freshness", {}) reviewed = dt.date.fromisoformat(posture["reviewed"]) review_due = dt.date.fromisoformat(posture["review_due"]) @@ -92,6 +93,22 @@ def validate_semantics(document: dict[str, Any], path: pathlib.Path) -> list[str f"{name}: implemented {axis}{level} must be above current {axis}{current_level}" ) + for key, freshness in evidence_freshness.items(): + if key not in evidence: + errors.append(f"{name}: evidence_freshness {key} has no evidence entry") + observed_at = dt.datetime.fromisoformat( + freshness["observed_at"].replace("Z", "+00:00") + ) + valid_until_raw = freshness.get("valid_until") + if valid_until_raw: + valid_until = dt.datetime.fromisoformat( + valid_until_raw.replace("Z", "+00:00") + ) + if valid_until <= observed_at: + errors.append( + f"{name}: evidence_freshness {key} valid_until must be after observed_at" + ) + provider = entry.get("provider", {}) for axis, reach in provider.get("axes", {}).items(): available = reach["available"] diff --git a/workplans/NK-WP-0031-deterministic-posture-feedback.md b/workplans/NK-WP-0031-deterministic-posture-feedback.md new file mode 100644 index 0000000..eb5d11f --- /dev/null +++ b/workplans/NK-WP-0031-deterministic-posture-feedback.md @@ -0,0 +1,111 @@ +--- +id: NK-WP-0031 +type: workplan +title: "Implement deterministic posture and evidence feedback" +domain: infotech +repo: net-kingdom +status: blocked +owner: codex +topic_slug: netkingdom +planning_priority: P1 +created: "2026-08-23" +updated: "2026-08-23" +--- + +# NK-WP-0031 — Deterministic posture and evidence feedback + +Authorized by the operator as the next locally owned increment after +NK-WP-0030. This implements the safe first C6 loop proposed by the 2026-08-23 +scope-to-intent assessment: surface explicit drift as proposals without +autonomous policy or work-record mutation. + +## Define freshness, owner, and proposal semantics + +```task +id: NK-WP-0031-T01 +status: done +priority: high +``` + +Extend Tenancy Posture declarations with optional authoritative repository and +evidence-freshness metadata. Define deterministic time, owner resolution, +finding severity, stable identity, and the proposal-only safety boundary. + +Implemented in Tenancy Posture draft-14, Posture Feedback v0.1, and their +schemas. Missing authoritative ownership or freshness resolves to `unknown`; +source-path normalization never becomes owner inference. + +## Implement the posture feedback evaluator + +```task +id: NK-WP-0031-T02 +status: done +priority: high +``` + +Validate declarations and emit deterministic JSON findings for overdue/due-soon +reviews, unknown/expired/due-soon evidence, implemented-but-unevidenced levels, +and declared gaps. Require explicit `--as-of`; never perform external writes. + +Implemented under `tools/posture-feedback/`. The emitted report declares that +external, policy, and declaration writes are all forbidden. + +## Publish reference workflow and tests + +```task +id: NK-WP-0031-T03 +status: done +priority: high +``` + +Provide an example declaration and cover freshness boundaries, missing owners, +multi-service inputs, stable digests, severity exit thresholds, and invalid +declarations. + +The checked-in expired-E2 example yields five deterministic proposals at its +fixed evaluation time. Focused coverage includes every listed boundary plus +portable source identity. + +## Obtain audit-core freshness adoption + +```task +id: NK-WP-0031-T04 +status: wait +priority: high +``` + +Ask `audit-core` to add authoritative owner and E2 freshness metadata for +`WH-ENG-20260822-AUDIT-E2-03`. The evaluator must report its freshness as +unknown until the source declaration supplies the exact timestamps and +replacement action; NetKingdom must not parse them from prose. + +Requested in State Hub message `874e5fa5-e05c-4b1a-a915-26c4dba07b87`. +The same estate baseline found pre-existing missing evidence entries for +`railiance-platform` `apps-pg` current R2 and V1; that separate correction was +routed in `d65f8383-bdab-488e-a36f-f8cabd8a5f65`. + +## Verify and reconcile + +```task +id: NK-WP-0031-T05 +status: done +priority: medium +``` + +Run focused and repository regression tests, validate schemas and examples, +reconcile State Hub, and publish the implementation evidence. + +Verification on 2026-08-23: + +- 28 focused posture-feedback and tenancy-validator tests passed; +- 84 root tests passed with the independently packaged `local-identity` + project excluded, then all 142 `local-identity` tests passed in its own + environment; +- both JSON schemas parsed, the fixed-time reference target succeeded, and + `git diff --check` passed; +- reuse-surface validated the capability registry entry; and +- the read-only seven-declaration baseline is recorded in + `history/2026-08-23-posture-feedback-estate-baseline.md`. + +Local implementation is complete. The workplan remains blocked only on T04's +externally owned audit-core declaration adoption.