feat(posture): add deterministic feedback proposals
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02929-244b-7391-b933-c04010e8eedb
This commit is contained in:
parent
dc8da422f8
commit
cfc9e7d0cb
19 changed files with 1428 additions and 16 deletions
13
Makefile
13
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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
26
SCOPE.md
26
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/`
|
||||
|
|
|
|||
145
canon/schemas/posture-feedback-report_v0.1.schema.json
Normal file
145
canon/schemas/posture-feedback-report_v0.1.schema.json
Normal file
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
109
canon/standards/posture-feedback_v0.1.md
Normal file
109
canon/standards/posture-feedback_v0.1.md
Normal file
|
|
@ -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.<level>.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 `<repo>/<path>` 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.
|
||||
|
|
@ -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 <path>...`; 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.<level>` 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 |
|
||||
|
|
|
|||
16
examples/posture-feedback/README.md
Normal file
16
examples/posture-feedback/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
60
examples/posture-feedback/expired-e2.yaml
Normal file
60
examples/posture-feedback/expired-e2.yaml
Normal file
|
|
@ -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
|
||||
47
history/2026-08-23-posture-feedback-estate-baseline.md
Normal file
47
history/2026-08-23-posture-feedback-estate-baseline.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
tools/posture-feedback/README.md
Normal file
22
tools/posture-feedback/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
440
tools/posture-feedback/posture_feedback.py
Normal file
440
tools/posture-feedback/posture_feedback.py
Normal file
|
|
@ -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())
|
||||
261
tools/posture-feedback/tests/test_posture_feedback.py
Normal file
261
tools/posture-feedback/tests/test_posture_feedback.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
111
workplans/NK-WP-0031-deterministic-posture-feedback.md
Normal file
111
workplans/NK-WP-0031-deterministic-posture-feedback.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue