diff --git a/AGENTS.md b/AGENTS.md index 0a1ba1f0..ca4a6665 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ make build # build wheel and source distribution make lint # Ruff make typecheck # strict mypy make test # unit test suite +make catalog # static security catalog tests and validation make smoke # API and worker process smoke suite make check # all required repository checks ``` diff --git a/Makefile b/Makefile index abc4759d..60017d92 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install build lint typecheck test smoke check +.PHONY: install build lint typecheck test catalog smoke check install: uv sync --frozen @@ -15,7 +15,11 @@ typecheck: test: uv run pytest -q tests/unit +catalog: + uv run pytest -q tests/catalog + uv run python scripts/validate_security_catalog.py + smoke: uv run pytest -q tests/process -check: build lint typecheck test smoke +check: build lint typecheck test catalog smoke diff --git a/README.md b/README.md index 8c360c12..755722ce 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ make build # 构建 wheel 和 sdist make lint # Ruff make typecheck # strict mypy make test # 单元测试 +make catalog # 安全目录静态测试与校验 make smoke # API / worker 进程 smoke -make check # build + lint + typecheck + test + smoke +make check # build + lint + typecheck + test + catalog + smoke ``` 本地启动 API: diff --git a/docs/decisions/0019-security-catalog-normalization.md b/docs/decisions/0019-security-catalog-normalization.md new file mode 100644 index 00000000..cacaaa72 --- /dev/null +++ b/docs/decisions/0019-security-catalog-normalization.md @@ -0,0 +1,119 @@ +--- +name: adr-0019-security-catalog-normalization +version: "1.0.0" +description: > + Normalize the release security catalog to fifteen stable invariant IDs and + distinguish canonical acceptance scenarios from derived evidence cases. +--- + +# 0019. Normalize the release security catalog to fifteen stable IDs + +- Status: accepted +- Date: 2026-07-20 + +## Context + +The security checklist, test architecture, and historical design accumulated +overlapping labels for the same release vetoes. Counting those labels as +independent invariant families produced a nineteen-entry prose list, while an +older acceptance section promoted ten later parameterizations into additional +top-level scenarios. Neither expansion added a new security boundary, but both +made release reports and milestone exits ambiguous. + +The hard oracles, sealed authorization ordering, trusted delivery construction, +audience intersection, ACL-proof behavior, revocation behavior, and single +release owner are already fixed by the +[threat model](../security/context-engine-threat-model.md), +[ADR-0003](0003-group-chat-intersection-authorization.md), +[ADR-0010](0010-policy-epoch-revocation.md), +[ADR-0012](0012-sealed-authorization-projection-pipeline.md), +[ADR-0013](0013-trusted-delivery-egress-and-capability-taxonomy.md), +[ADR-0014](0014-curation-snapshot-and-release-ownership.md), and +[ADR-0017](0017-trusted-invocation-and-closed-runtime-access.md). Catalog +normalization must not weaken or renumber those safeguards. + +## Decision + +The canonical release catalog contains exactly these fifteen stable IDs, in +this order: + +1. `TENANT-OWNERSHIP-001` +2. `TENANT-FK-002` +3. `RLS-FAIL-CLOSED-003` +4. `SCOPE-INTERSECTION-004` +5. `INDEX-NOT-AUTHORITY-005` +6. `REVOCATION-006` +7. `WORKER-LEASE-007` +8. `TRANSPORT-UNTRUSTED-008` +9. `NON-ENUMERATION-009` +10. `CITATION-AUTH-010` +11. `EGRESS-011` +12. `TRACE-REDACTION-012` +13. `ACTION-SEPARATION-014` +14. `CROSS-ORG-LEARN-015` +15. `RELEASE-OWNER-019` + +`eval/catalogs/security-invariants.yaml` is the machine authority for this +set. `eval/catalogs/security-catalog.schema.json` validates its shape, and +`python3 scripts/validate_security_catalog.py` validates the catalog and its +tracked document references. The catalog uses JSON-compatible YAML so the D0 +validator remains standard-library-only and deterministic in bootstrap and CI, +independent of application dependencies. + +The following labels retain all of their tests and safeguards but are not +additional canonical release IDs: + +- `AUDIENCE-016` is covered by `SCOPE-INTERSECTION-004` plus `EGRESS-011`. +- `ACL-PROOF-017` is covered by `INDEX-NOT-AUTHORITY-005` plus + `REVOCATION-006`. +- `DELIVERY-EVIDENCE-018` is covered by `TRANSPORT-UNTRUSTED-008`. + +`CACHE-SCOPE-013` remains a preregistered conditional extension outside the +canonical fifteen. It becomes applicable with the first authorization-sensitive +final `ContextPackage` or `AuthorizedProjection` cache. Activating that +capability requires a future versioned catalog and schema change that adds the +extension and its proving cases; until then, composition tests must prove that +no such cache is active. Existing numbering is never reused or shifted. + +The canonical V1 acceptance fixture has twelve top-level scenarios: +`ACCEPT-001` cross-Organization isolation (one fixture with bidirectional A/B +assertions), `ACCEPT-002` same-Organization Membership isolation, +`ACCEPT-003` Agent ceiling, `ACCEPT-004` request narrowing, `ACCEPT-005` +revocation, `ACCEPT-006` hostile index, `ACCEPT-007` transport injection, +`ACCEPT-008` WorkerLease replay/binding, `ACCEPT-009` source-native ACL, +`ACCEPT-010` citation revocation, `ACCEPT-011` denied/not-found equivalence, +and `ACCEPT-012` Context/Action separation. Cases historically numbered 13 +through 22 remain required parameterized or derived cases mapped to those +twelve scenarios or directly to invariant evidence. They are not ten +additional top-level acceptance IDs. + +## Rationale + +One stable machine-readable set makes release completeness mechanically +checkable and prevents prose counts from becoming a second authority. Absorbing +overlapping labels preserves their negative cases while making each release +veto independently reportable. Keeping the cache rule conditional avoids +claiming an inactive cache capability while ensuring its security gate is +defined before activation. + +## Consequences + +Security documentation and generated reports must use the exact fifteen IDs +and may not describe the absorbed labels as extra families. Every absorbed or +derived case still needs evidence under its mapped canonical invariant. A +validator failure, unmapped active case, or missing evidence is a release +failure; normalization cannot turn it into `NOT_ACTIVE` or +`NOT_APPLICABLE`. + +Reviewers can compare release reports without ID churn. Adding a genuinely new +security boundary requires an explicit catalog/schema version change and an +ADR; it cannot be introduced by silently extending a prose table. + +## Revisit trigger + +Reopen when a new implemented security boundary cannot be represented by the +canonical fifteen, or when an authorization-sensitive final +`ContextPackage`/`AuthorizedProjection` cache is first activated. Any revision +must preserve existing IDs and evidence history, name the new proving seam and +milestone applicability, update the versioned machine catalog and schema, and +retain the three hard zero oracles. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 74accc89..8c55e5af 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -21,6 +21,7 @@ kernel, capability separation, and publication visibility model. | Trusted access boundary | [0017 — Trusted invocation and closed Runtime access](0017-trusted-invocation-and-closed-runtime-access.md) | HTTP, generated SDK, and activated MCP map to one Runtime contract; trusted inputs are ingress-built | Caller-supplied identity/ACL/audience, transport-local policy, or IM as a fourth transport | | Read versus effect | [0011 — Read/write plane separation](0011-read-write-plane-separation.md) | `ContextAccessTicket` and `ActionTicket` use different audiences and are non-interchangeable | Using content/read authority to execute an external effect | | Publication visibility | [0018 — Immutable ContextRevision publication](0018-immutable-revision-publication.md) | `ContextResource` content is immutable `ContextRevision`/`ContextFragment` lineage; one transaction changes the active pointer | In-place content mutation, mixed old/new reads, or cleanup-defined visibility | +| Release security catalog | [0019 — Security catalog normalization](0019-security-catalog-normalization.md) | One machine catalog contains exactly fifteen stable release IDs; overlapping labels and derived scenarios keep their safeguards without inflating the count | Parallel prose catalogs, renumbering, or treating inactive cache behavior as a canonical release family | Each baseline ADR is `accepted` and contains Context, Decision, Rationale, Consequences, and Revisit trigger sections. A revisit trigger permits review; it @@ -87,3 +88,4 @@ touched: - [0014 — Curation snapshot and release ownership](0014-curation-snapshot-and-release-ownership.md) - [0015 — RLS transaction context and schema manifest](0015-rls-transaction-context-and-schema-manifest.md) - [0016 — Implementation authority and vertical-slice roadmap](0016-implementation-authority-and-vertical-slice-roadmap.md) +- [0019 — Security catalog normalization](0019-security-catalog-normalization.md) diff --git "a/docs/security/Test-Architecture-\344\270\216\345\217\257\351\252\214\350\257\201\346\200\247\350\256\276\350\256\241.md" "b/docs/security/Test-Architecture-\344\270\216\345\217\257\351\252\214\350\257\201\346\200\247\350\256\276\350\256\241.md" index 17804a37..6a58a684 100644 --- "a/docs/security/Test-Architecture-\344\270\216\345\217\257\351\252\214\350\257\201\346\200\247\350\256\276\350\256\241.md" +++ "b/docs/security/Test-Architecture-\344\270\216\345\217\257\351\252\214\350\257\201\346\200\247\350\256\276\350\256\241.md" @@ -9,7 +9,7 @@ tags: - system-design - postgres created: 2026-07-15 -updated: 2026-07-19 +updated: 2026-07-20 source: "context-engine-threat-model.md and ../research/2026-07-19-four-public-repositories-evidence.md" --- @@ -124,7 +124,7 @@ interface ActionPlane { ### 4. Security invariant catalog -每条 invariant 同时拥有 domain property、Postgres integration 与 runtime negative test。任何一层失败都阻止发布。 +发布目录的唯一机器权威是 [`eval/catalogs/security-invariants.yaml`](../../eval/catalogs/security-invariants.yaml),其 schema 是 [`eval/catalogs/security-catalog.schema.json`](../../eval/catalogs/security-catalog.schema.json),验证命令是 `python3 scripts/validate_security_catalog.py`。它使用 JSON-compatible YAML,使校验器在 bootstrap 与 CI 中始终只依赖 Python 标准库并保持确定性,与应用依赖解耦。[ADR-0019](../decisions/0019-security-catalog-normalization.md) 固定恰好 15 个 canonical release ID。每条 invariant 同时拥有适用的 domain property、Postgres integration 与 runtime/delivery negative evidence;任何 required seam 失败都阻止发布。 | ID | Invariant | 最小测试 | |---|---|---| @@ -136,18 +136,16 @@ interface ActionPlane { | `REVOCATION-006` | engine观察到access change后先bump Policy Epoch,下一请求失效 | cache/index未清理仍不可见;已发送bytes由独立egress历史策略处理 | | `WORKER-LEASE-007` | ServiceActor/WorkerLease精确绑定org、job、operation、source、可选resource/revision、workload、epoch、可选audience、idempotency、generation、iat/exp、nonce | 逐claim变异、durable job row不匹配、过期、旧generation、replay或伪装UserActor均拒绝 | | `TRANSPORT-UNTRUSTED-008` | HTTP/MCP body不能自报org/user/audience/ACL/raw SQL/bypass;SDK只是HTTP client artifact | schema拒绝trusted字段,ingress仅从认证会话或已兑换DeliveryEvidenceRef构造context | -| `NON-ENUMERATION-009` | missing 与 unauthorized 对 caller 等价 | status/body/latency bucket 无资源存在性差异 | +| `NON-ENUMERATION-009` | missing 与 unauthorized 对 caller 等价 | M1 固定 status/body/domain outcome/shape/count,不声明 timing 等价;M5/E5 再运行预注册、有统计功效的 timing gate | | `CITATION-AUTH-010` | CitationOpenRef不授予权限且每次open授权;ContinuationToken独立、scope-bound且one-shot | 两类token不可互换;wrong opener/revoke后返回0 bytes | | `EGRESS-011` | sensitivity/purpose/provider/region/audience做交集,TCB逐跳验证egress grant | disallowed ModelGateway/Sender receives zero payload/effect | | `TRACE-REDACTION-012` | ContextRun只含authorized refs;restricted DecisionAudit不含raw denied content | tenant-visible run/debug无raw denied;security audit只有opaque digest/category | -| `CACHE-SCOPE-013` | cache key 至少含 org/subject/agent/purpose/policy/config/asOf | 同 query 跨 membership 不能命中同 Package | | `ACTION-SEPARATION-014` | Context read不能授予write;外部效果只经ActionPlane.prepare/perform的closed outcome,create/finalize是不同one-shot effect | bypass prepare、wrong/cross-effect/replayed ticket为business effect=0;成功replay返回存量receipt,ambiguous attempt以原id对账 | | `CROSS-ORG-LEARN-015` | V1 无跨租户 raw learning | feedback/eval export 按 org;global artifact raw refs 为空 | -| `AUDIENCE-016` | TrustedDeliveryContext携带AudienceSnapshot事实,Kernel而非BotDelivery计算群scope | caller伪造/未知成员/发送前snapshot变化时public bytes=0 | -| `ACL-PROOF-017` | live/mirrored/weak proof语义显式,strong不可用时不得降为weak | strong provider失败只会fail closed/typed gap或同强度proof | -| `DELIVERY-EVIDENCE-018` | remote BotDelivery每次resolve只传opaque DeliveryEvidenceRef,public/private引用分离 | 伪造、cross-request replay、wrong service/destination/purpose、expired ref在retrieval/content work前失败 | | `RELEASE-OWNER-019` | ContextLearning.evaluate/promote是唯一ReleaseManifest评测与激活路径 | Control/curation/bootstrap直写active pointer=0;初始manifest只经promote | +归并只消除重复的发布标签,不消除证明义务:原 `AUDIENCE-016` cases 由 `SCOPE-INTERSECTION-004` + `EGRESS-011` 承担,原 `ACL-PROOF-017` cases 由 `INDEX-NOT-AUTHORITY-005` + `REVOCATION-006` 承担,原 `DELIVERY-EVIDENCE-018` cases 由 `TRANSPORT-UNTRUSTED-008` 承担。`CACHE-SCOPE-013` 是 canonical 15 之外的预注册条件扩展;当前 composition test 必须证明 authorization-sensitive 的最终 `ContextPackage`/`AuthorizedProjection` cache 不可达。首次激活时必须先用版本化 catalog/schema 变更加入该扩展及完整 cache-key mutation suite,且当次结果只能以 `PASS` 开门。所有既有 test cases 保留,ID 不重排、不复用。 + > **Security suite 的目标是证明数据无法进入 Evidence,不是最终答案碰巧没显示它。** ### 5. Property-based authorization tests @@ -400,32 +398,39 @@ Flaky test 不能自动重跑后变绿;先记录原始失败,只有 typed ex ### 13. V1 acceptance scenarios -同一个 Agent、同一句 Query,最小 fixture 必须同时证明: - -1. Organization A/User A 只得到 A 的 Resource/field; -2. Organization B/User B 只得到 B; -3. 同 Organization 的 Membership A/B 得到不同 Resource/field; -4. Agent ceiling 比 User 小时只返回交集; -5. request source/filter 只能收窄; -6. revoke后下一次request、旧cache、旧continuation都不可见; -7. index 故意返回 cross-org candidate 仍不进入 Evidence; -8. HTTP body自报org/ACL/raw SQL被schema拒绝;MCP激活后同一fixture也必须拒绝; -9. Worker使用ServiceActor,WorkerLease每个claim与durable job row匹配,wrong-org/generation/workload/job/source/operation与replay被拒绝; -10. federated Provider 对 source-native ACL 做最后授权; -11. citation/open-original 在 revoke 后失败且不暴露存在性; -12. Context 与 Action 使用不同 audience/capability; -13. CandidateRef没有content;Runtime rerank/Assembler只接AuthorizedProjection,ModelGateway只接BotDelivery从单一当前Package构造的AuthorizedModelInput与匹配EgressGrant; -14. strong ACL proof故障不会降级为weak,mirrored/weak Package明确暴露proof kind与freshness; -15. caller不能伪造TrustedDeliveryContext/AudienceSnapshot;DeliveryEvidenceRef伪造、跨request重放、wrong service/destination或过期均在content work前失败,未知成员不产生public Package; -16. 群公开与asker私聊是两次resolve,send-time成员变化与future-member history策略均fail closed; -17. CitationOpenRef与ContinuationToken不能互换,前者每次按opener授权、后者one-shot; -18. create/finalize分别经ActionPlane.prepare/perform closed outcome,各自只能产生一次对应effect;成功replay返回存量receipt,ambiguous attempt用原id对账; -19. ContextRun只含authorized lineage,DecisionAudit对tenant caller不可见且无raw denied body; -20. 每张数据库表进入schema security manifest,tenant表由non-owner/NOBYPASSRLS role在transaction-local Organization+ActorContext下证明隔离; -21. FileProvider仅使用active、版本化FileSourceAccess,missing/incomplete/unknown grant严格deny,不从OS owner或默认public推断; -22. 初始empty ReleaseManifest也由授权release operator通过唯一`ContextLearning.promote`路径激活,bootstrap/migration/ContextControl直写active pointer均失败。 - -这 22 项组成V1 release smoke catalog;每个milestone只对它明确列为required的已激活项要求`PASS`。未激活路径必须标`NOT_ACTIVE`并证明不可达,但该状态不能完成required exit;不得以“未来补security suite”或未实现即通过。 +同一个 Agent、同一句 Query,canonical 最小 fixture 固定为以下 12 个具名顶层场景: + +| Acceptance ID | 顶层场景 | 必需断言 | +|---|---|---| +| `ACCEPT-001` | cross-Organization isolation | Organization A/User A 只得到 A 的 Resource/field,Organization B/User B 只得到 B;同一 fixture 双向参数化断言,cross-org Evidence/effect 均为 0 | +| `ACCEPT-002` | same-Organization Membership isolation | 同一 Organization 的 Membership A/B 得到不同 Resource/field | +| `ACCEPT-003` | Agent ceiling intersection | Agent ceiling 比 User 小时只返回交集 | +| `ACCEPT-004` | request narrowing | request source/filter 只能收窄,不能扩大 trusted scope | +| `ACCEPT-005` | revocation | M0 对不可用 continuation 做 generic refusal 且 Provider/Index/Source I/O=0;真实 carrier 激活后,revoke 后下一次 request、旧 cache、旧 continuation 均不可见 | +| `ACCEPT-006` | hostile index candidate | index 故意返回 cross-org candidate 仍不进入 AuthorizedProjection/Evidence 或 content-bearing consumer | +| `ACCEPT-007` | transport injection rejection | HTTP body 自报 org/ACL/raw SQL 被 schema 拒绝;MCP 激活后同一 fixture 也必须拒绝 | +| `ACCEPT-008` | WorkerLease replay and binding | Worker 使用 ServiceActor;WorkerLease 每个 claim 与 durable job row 匹配,wrong-org/generation/workload/job/source/operation 与 replay 被拒绝 | +| `ACCEPT-009` | source-native ACL | federated Provider 对 source-native ACL 做最后授权,proof 不可静默降级 | +| `ACCEPT-010` | citation revocation | citation/open-original 在 revoke 后失败且不暴露存在性 | +| `ACCEPT-011` | denied/not-found equivalence | M1 冻结 cross-org、same-org denied 与 missing reference 的 status/body/domain outcome/shape/count 等价,不声明 timing 等价;M5/E5 再按预注册、有统计功效的 timing 门槛执行 | +| `ACCEPT-012` | Context/Action separation | Context 与 Action 使用不同 audience/capability,read authority 不产生 write effect | + +早期编号 13–22 不是额外顶层 acceptance ID,而是下面这些仍然必跑的参数化/派生案例或 invariant evidence: + +| 历史编号 | 保留的证明义务 | 映射 | +|---|---|---| +| 13 | CandidateRef没有content;Runtime rerank/Assembler只接AuthorizedProjection;ModelGateway只接由单一当前Package构造的AuthorizedModelInput与匹配EgressGrant | `ACCEPT-006`;`INDEX-NOT-AUTHORITY-005`、`EGRESS-011` | +| 14 | strong ACL proof故障不降为weak;mirrored/weak Package暴露proof kind与freshness | `ACCEPT-009`;`INDEX-NOT-AUTHORITY-005`、`REVOCATION-006` | +| 15 | caller不能伪造TrustedDeliveryContext/AudienceSnapshot;DeliveryEvidenceRef伪造、跨request重放、wrong service/destination或过期均在content work前失败;未知成员不产生public Package | `ACCEPT-007`;`TRANSPORT-UNTRUSTED-008`、`SCOPE-INTERSECTION-004`、`EGRESS-011` | +| 16 | 群公开与asker私聊是两次resolve;send-time成员变化与future-member history策略fail closed | `ACCEPT-012`;`SCOPE-INTERSECTION-004`、`EGRESS-011` | +| 17 | CitationOpenRef与ContinuationToken不能互换;前者每次按opener授权,后者one-shot | `ACCEPT-010`;`CITATION-AUTH-010`、`NON-ENUMERATION-009` | +| 18 | create/finalize各经ActionPlane.prepare/perform closed outcome、各自最多一个对应effect;成功replay返存量receipt,ambiguous attempt用原id对账 | `ACCEPT-012`;`ACTION-SEPARATION-014` | +| 19 | ContextRun只含authorized lineage;DecisionAudit对tenant caller不可见且无raw denied body | invariant evidence;`TRACE-REDACTION-012` | +| 20 | 每张数据库表进入schema security manifest;tenant表由non-owner/NOBYPASSRLS role在transaction-local Organization+ActorContext下证明隔离 | `ACCEPT-001`、`ACCEPT-002`;`TENANT-OWNERSHIP-001`、`TENANT-FK-002`、`RLS-FAIL-CLOSED-003` | +| 21 | FileProvider只使用active、版本化FileSourceAccess;missing/incomplete/unknown grant严格deny,不从OS owner或默认public推断 | `ACCEPT-009`;`INDEX-NOT-AUTHORITY-005`、`REVOCATION-006` | +| 22 | 初始empty ReleaseManifest只由授权release operator经`ContextLearning.promote`激活;bootstrap/migration/ContextControl直写active pointer失败 | invariant evidence;`RELEASE-OWNER-019` | + +这 12 个顶层场景组成 V1 release smoke fixture;派生案例和 invariant evidence 不增加顶层计数,也不得遗漏。每个milestone只对它明确列为required的已激活项要求`PASS`。未激活路径必须标`NOT_ACTIVE`并证明不可达,但该状态不能完成required exit;不得以“未来补security suite”或未实现即通过。 > **最小产品可以先只支持一个File Source,最小安全模型不能只支持 happy path。** @@ -471,13 +476,14 @@ Visible self-correction:早期设计把“多写一些 unit tests”当可测 ## References +- [ADR-0019: Security catalog normalization](../decisions/0019-security-catalog-normalization.md) - [Four Public Repositories Evidence Baseline](../research/2026-07-19-four-public-repositories-evidence.md) - [PostgreSQL 17 Row Security Policies](https://www.postgresql.org/docs/17/ddl-rowsecurity.html) - [ContextEngine implementation design](../design/2026-07-18-context-engine-implementation-design.md) ## Questions / Next Steps -- [ ] 把全部security invariant编码为executable catalog,CI自动检查每条至少有property+DB+runtime case与四态coverage status。 +- [x] 以 `eval/catalogs/security-invariants.yaml` 固定 canonical 15,并由 `python3 scripts/validate_security_catalog.py` 校验 schema、顺序、唯一性与文档引用;各 milestone 的 executable proving-case harness 仍随实现加入。 - [ ] 用 Testcontainers/Postgres 17 建 non-owner+FORCE RLS harness。 - [ ] 先写`ContextRuntime.resolve(Acquire | Continue | OpenCitation)`的in-memory behavior harness与真实Postgres security tests,再将它固定到HTTP/OpenAPI。 - [ ] 定义 V1 golden corpus:两个 Organization、同Organization两个Membership、File Source、后续Provider twin、撤权与stale candidate cases;样本计划按slice coverage与uncertainty/power预注册。 @@ -490,3 +496,5 @@ Visible self-correction:早期设计把“多写一些 unit tests”当可测 *Updated 2026-07-18*: adopted implementation design冻结content-free CandidateRef→Kernel-only AuthorizedProjection→Runtime content work与Package→AuthorizedModelInput→ModelGateway两段类型顺序,收敛ContextProvider四操作、DeliveryEvidenceRef、ServiceActor/WorkerLease、closed ActionPlane outcomes、唯一Learning promotion与真实Postgres transaction/RLS全表manifest验收。 *Updated 2026-07-19*: 将测试论证收口到 ContextEngine 自有威胁模型与四个公开参考仓证据基线;明确 fixture/fake/twin 可以证明内部失败语义,但不代替真实依赖的 capability evidence。 + +*Updated 2026-07-20*: 依据 ADR-0019 将发布 catalog 固定为 15 个 canonical ID,归并重叠标签而不删除 case,并将 acceptance 口径收敛为 12 个顶层场景加历史 13–22 的派生/参数化证据。 diff --git "a/docs/security/\345\256\211\345\205\250\350\264\237\345\220\221\346\265\213\350\257\225\346\270\205\345\215\225.md" "b/docs/security/\345\256\211\345\205\250\350\264\237\345\220\221\346\265\213\350\257\225\346\270\205\345\215\225.md" index 867e7f6d..7bd574e1 100644 --- "a/docs/security/\345\256\211\345\205\250\350\264\237\345\220\221\346\265\213\350\257\225\346\270\205\345\215\225.md" +++ "b/docs/security/\345\256\211\345\205\250\350\264\237\345\220\221\346\265\213\350\257\225\346\270\205\345\215\225.md" @@ -7,7 +7,7 @@ tags: - information-security - quality-assurance created: 2026-07-15 -updated: 2026-07-19 +updated: 2026-07-20 source: "context-engine-threat-model.md and ../research/2026-07-19-four-public-repositories-evidence.md" --- @@ -19,11 +19,11 @@ source: "context-engine-threat-model.md and ../research/2026-07-19-four-public-r ## Content -ContextEngine 的负向测试 catalog 从自有威胁模型与 release contract 推导,把租户越权、缺失上下文、证据泄漏、授权时序、工作者身份、audience drift、egress 与外部 effect 分离转成稳定 test IDs。四个公开参考仓只用于校验 Adapter、parser/retrieval、preview/release 和真实依赖测试的工程形状;不继承其安全保证,也不声称已运行上游渗透、故障注入或 ContextEngine 动态 Spike。一手证据和未取证边界见[Four Public Repositories Evidence Baseline](../research/2026-07-19-four-public-repositories-evidence.md)。 +ContextEngine 的负向测试 catalog 从自有威胁模型与 release contract 推导,把租户越权、缺失上下文、证据泄漏、授权时序、工作者身份、audience drift、egress 与外部 effect 分离转成稳定 test IDs。[ADR-0019](../decisions/0019-security-catalog-normalization.md) 将发布权威归一为恰好 15 个稳定 ID;机器权威是 [`eval/catalogs/security-invariants.yaml`](../../eval/catalogs/security-invariants.yaml),由 [`eval/catalogs/security-catalog.schema.json`](../../eval/catalogs/security-catalog.schema.json) 和 `python3 scripts/validate_security_catalog.py` 校验。目录采用 JSON-compatible YAML,使校验器在 bootstrap 与 CI 中始终只依赖 Python 标准库并保持确定性,与应用依赖解耦。四个公开参考仓只用于校验 Adapter、parser/retrieval、preview/release 和真实依赖测试的工程形状;不继承其安全保证,也不声称已运行上游渗透、故障注入或 ContextEngine 动态 Spike。一手证据和未取证边界见[Four Public Repositories Evidence Baseline](../research/2026-07-19-four-public-repositories-evidence.md)。 这里的“无 mock 能力声明”是证据门槛,不是禁止测试替身。deterministic fixture、fake、twin、spy 与 property generator 可以证明内部失败语义和“调用/bytes/effect = 0”;只有真实 PostgreSQL、真实 wire、真实 source sandbox 或真实外部依赖运行结果才能支持 live capability claim。 -测试接受两个代价:PR 必须运行真实 PostgreSQL 17 non-owner RLS suite,速度高于纯 unit;denied/not-found non-enumeration 需要 response/timing 归一化,debug 便利度下降。security gate 不与 retrieval quality、latency 或总分平均。 +测试接受两个代价:PR 必须运行真实 PostgreSQL 17 non-owner RLS suite,速度高于纯 unit;denied/not-found non-enumeration 在 M1 归一化 status/body/headers/domain outcome/shape/count,并在 M5/E5 额外执行预注册、有统计功效的 timing gate,debug 便利度下降。security gate 不与 retrieval quality、latency 或总分平均。 > **一个 unauthorized Fragment 即使最终没出现在答案里,也已经是 Context Runtime failure。** @@ -39,18 +39,18 @@ ContextEngine 的负向测试 catalog 从自有威胁模型与 release contract | `REVOCATION-006` | engine观察到revoke后下一request/continuation失效;已发送bytes按egress历史策略处理 | stale cache/continuation/citation可见数=0;历史消息有显式删除/收窄策略 | | `WORKER-LEASE-007` | ServiceActor/WorkerLease绑定org、job、operation、source、可选resource/revision、workload、epoch、可选audience、idempotency、generation、iat/exp、nonce | 逐claim与durable job row不匹配、user impersonation、过期/旧generation或replay business effect=0 | | `TRANSPORT-UNTRUSTED-008` | body不能自报trusted context;remote BotDelivery只在authenticated metadata传opaque DeliveryEvidenceRef | tenant/user/audience/ACL/SQL/bypass字段schema拒绝;ref兑换前content work=0 | -| `NON-ENUMERATION-009` | denied与not-found对caller等价 | status/body/error code/timing bucket一致 | +| `NON-ENUMERATION-009` | denied与not-found对caller等价 | M1:status/body/headers/domain outcome/shape/count一致,不声明 timing 等价;M5/E5:预注册、有统计功效的 timing gate 通过 | | `CITATION-AUTH-010` | CitationOpenRef不授予权限、每次open重新授权;ContinuationToken独立且one-shot | wrong opener/revoke后bytes/fields返回=0;两类token不能互换 | | `EGRESS-011` | trusted ingress、BotDelivery、ModelGateway、ActionPlane、Sender属于delivery TCB;sensitivity/purpose/provider/region/audience做交集 | 无匹配EgressGrant或AuthorizedModelInput时outbound bytes=0 | | `TRACE-REDACTION-012` | ContextRun只含authorized记录;restricted DecisionAudit不含raw denied content | tenant-visible run与日志中secret/raw denied match=0 | -| `CACHE-SCOPE-013` | key/envelope绑定完整identity+policy+config | cross-membership/package collision=0 | | `ACTION-SEPARATION-014` | write只经ActionPlane.prepare→perform closed outcomes;每effect使用不同one-shot ActionTicket | 无prepare、wrong payload/destination/audience、跨effect effect=0;成功replay只返存量receipt,ambiguous attempt用原id对账 | | `CROSS-ORG-LEARN-015` | V1无raw跨租户learning | global artifact raw refs=0;无opt-in artifact=0 | -| `AUDIENCE-016` | TrustedDeliveryContext/AudienceSnapshot只能来自trusted binding;Kernel计算群scope | caller伪造成员或未知成员导致public effect=0 | -| `ACL-PROOF-017` | live/mirrored/weak ACL proof语义显式且不可strong→weak降级 | strong proof不可得时Evidence=0或typed gap,不以weak继续 | -| `DELIVERY-EVIDENCE-018` | remote BotDelivery只传opaque DeliveryEvidenceRef,ingress兑换为TrustedDeliveryContext | 伪造、跨request replay、wrong service/destination、expired ref在content work前失败 | | `RELEASE-OWNER-019` | ContextLearning.evaluate/promote是唯一ReleaseManifest评测与激活路径 | Control/curation/bootstrap直写active pointer=0;initial manifest只经promote | +[ADR-0019](../decisions/0019-security-catalog-normalization.md) 只归并发布 ID,不删除任何 safeguard 或 case:原 `AUDIENCE-016` 全部语义映射到 `SCOPE-INTERSECTION-004` + `EGRESS-011`;原 `ACL-PROOF-017` 映射到 `INDEX-NOT-AUTHORITY-005` + `REVOCATION-006`;原 `DELIVERY-EVIDENCE-018` 映射到 `TRANSPORT-UNTRUSTED-008`。下文对应的 `AUTH-*`、`DELIV-*`、`PROV-*`、`RUN-*`、`IM-*` 等案例继续执行,并在机器目录的 canonical invariant 下提供证据,不形成第 16–18 个发布 ID。 + +`CACHE-SCOPE-013` 保留为 canonical 15 之外的预注册条件扩展:当前必须用 composition/behavior case 证明不存在 authorization-sensitive 的最终 `ContextPackage` 或 `AuthorizedProjection` cache。首次激活这类 cache 时,必须先通过版本化 catalog/schema 变更加入 `CACHE-SCOPE-013` 及其 key/envelope 绑定完整 identity、policy、config、`asOf` 的 mutation cases;只有 `PASS` 才能发布。既有编号不重排、不复用。 + [Test Architecture and Verifiability Design](./Test-Architecture-与可验证性设计.md) 定义 portfolio;这份文档只保存安全 case 与精确 oracle。 每条 invariant 分开记录三个维度:在版本化 catalog 中预注册的 applicability(required、带`applicableFrom`的conditional,或带审批理由的NOT_APPLICABLE)、capability activation/coverage(unavailable、implemented、contract-verified、sandbox-verified、live-verified),以及 applicable active path的当次 PASS/FAIL结果。对外渲染态仍只能取 `PASS`、`FAIL`、`NOT_ACTIVE`、`NOT_APPLICABLE`。`NOT_APPLICABLE`必须在运行前由冻结 catalog 排除;`NOT_ACTIVE`必须证明该边界不可达;active但未运行、未映射或缺证据一律是`FAIL`。每个 milestone 的 required exit 只能由`PASS`满足,`NOT_ACTIVE`和`NOT_APPLICABLE`都不能替代。 @@ -223,8 +223,8 @@ ContextPackage snapshot同时检查 purpose、TTL、asOf、decisionRef、Policy | `OBS-003` | debug endpoint绕过normal auth或返回raw candidates | endpoint同Admin policy;production默认关闭;raw denied=0 | | `OBS-004` | ContextRun序列化raw/denied CandidateRef、denied count或source secret | write schema拒绝;只保存Package digest、authorized EvidenceRef、config/metric与可重建lineage | | `OBS-005` | tenant caller读取DecisionAudit或restricted security partition | zero rows/generic denied;只授权security operator可读opaque ref/digest与denial category,无raw denied body | -| `ENUM-001` | 按实验前预注册的timing/non-enumeration sample plan比较denied与missing status/body | 完全相同;rate limit不因存在性分叉;样本不足只报inconclusive | -| `ENUM-002` | denied/missing latency分布比较 | 同coarse bucket;超threshold进入security finding | +| `ENUM-001` | M1 确定性比较 denied 与 missing 的 status/body/headers/domain outcome/shape/count | 完全相同;rate limit不因存在性分叉;此阶段不声明 timing 等价 | +| `ENUM-002` | M5/E5 按实验前预注册的 sample size、effect-size threshold、noise control 与 uncertainty method 比较 denied/missing latency | 有统计功效的 timing gate 通过;样本不足只报 inconclusive,超 threshold 进入 security finding | | `LEARN-001` | Organization A feedback引用B run/evidence | composite ownership/authorization拒绝 | | `LEARN-002` | global artifact含raw fragment/embedding/trace | export gate拒绝;raw refs=0 | | `LEARN-003` | 无opt-in或低于aggregation threshold | 不生成global artifact | @@ -290,7 +290,7 @@ Visible self-correction:早期清单把“跨租户query返回不同答案” |---|---| | Every commit | property、real Postgres RLS/FK/outbox、Adapter contracts、Module behavior、core security matrix、capability status evidence | | Retrieval-sensitive PR | frozen retrieval/assembly + all security cases | -| Nightly | fuzz、fault injection、live Provider conformance、timing non-enumeration、large corpus | +| Nightly | fuzz、fault injection、live Provider conformance、large corpus;M5 激活后运行预注册、有统计功效的 timing non-enumeration | | Release candidate | packaged image、migrate、non-owner role、two-Org fixture、HTTP/generated SDK、worker/citation/action;MCP激活后再列入 | | Engineering Gate E5 | Security/Reliability/Quality/Budget required catalog条目全部PASS + Ops readiness;不以partner存在为前提 | | Launch Gate L1 | E5后单独验证design-partner agreement、legal review、命名与commercial approval;通过前不开放受邀使用 | @@ -309,7 +309,7 @@ Flaky deterministic security/reliability case不能自动重跑变绿。所有 ## Questions / Next Steps -- [ ] 把全部Invariant与test IDs写成YAML catalog,分开预注册applicability、activation/coverage与当次result;CI校验required exit只有PASS可开门。 +- [x] 以 `eval/catalogs/security-invariants.yaml` 固定 canonical 15,使用 `python3 scripts/validate_security_catalog.py` 校验 schema、顺序、唯一性与文档引用;case 到 invariant 的完整运行时映射仍由后续 harness 实现。 - [ ] 为ContextProvider、parser、HTTP ingress、ModelGateway、Sender发布共享contract/twin suite;PostgreSQL retrieval与worker保持内部implementation suite。 - [ ] 用mutation testing主动删除tenant predicate、cache key维度、epoch check与exact auth,证明suite能杀死错误。 - [ ] 在首个business Provider上运行SSRF/SQL/TOCTOU/field ACL corpus。 @@ -319,3 +319,5 @@ Flaky deterministic security/reliability case不能自动重跑变绿。所有 *Added 2026-07-15*: 建立 target executable negative catalog;重访触发为prototype、security finding、第二种Provider/placement/edition或新的transport。 *Updated 2026-07-19*: 将 catalog 溯源收口到 ContextEngine 自有威胁模型与四个公开参考仓证据基线;明确 deterministic test double 与 live capability evidence 的不同验收责任。 + +*Updated 2026-07-20*: 依据 ADR-0019 将发布目录归一为 15 个稳定 ID;保留被吸收标签的全部案例与 `CACHE-SCOPE-013` 条件扩展,并链接机器目录、schema 与 validator。 diff --git a/docs/specs/2026-07-19-context-engine-implementation-epic.md b/docs/specs/2026-07-19-context-engine-implementation-epic.md index e09819b4..28662711 100644 --- a/docs/specs/2026-07-19-context-engine-implementation-epic.md +++ b/docs/specs/2026-07-19-context-engine-implementation-epic.md @@ -50,14 +50,15 @@ Verified on 2026-07-19: | Design authority | Implementation Design v1.2 is authoritative; earlier drafts are non-authoritative history. | `docs/design/2026-07-18-context-engine-implementation-design.md:9` | | Baseline | A byte-level candidate exists but is not approved and is not immutable. | `DESIGN-BASELINE.md:1` | | Architecture | Five deep Modules and three processes by M2 are fixed. | implementation design sections 2 and 9 | -| Security | Security prose defines eighteen base families plus the release-owner family; their revocation IDs still need one generated canonical catalog. | `docs/security/Test-Architecture-与可验证性设计.md:123`, `docs/security/安全负向测试清单.md:28` | +| Security | ADR-0019 fixes exactly fifteen stable release IDs in the machine catalog; overlapping labels retain their cases under canonical IDs, and the canonical fixture is `ACCEPT-001` through `ACCEPT-012` plus derived evidence. | `eval/catalogs/security-invariants.yaml`, `docs/decisions/0019-security-catalog-normalization.md` | | Roadmap | D0, M0-M7, parallel C1, L1, and P3 are defined. | `PLAN.md:77`, implementation design section 9 | | Product contract | The program PRD contains 100 user stories and implementation/testing decisions. | `docs/agents/prd-contextengine-implementation.md` | | Issue backlog | Before this document, no child issue draft was stored. This spec is the repository draft; no GitHub parent or child issue has been created or authorized. | this spec, `docs/agents/issue-tracker.md`, `DESIGN-BASELINE.md` | -| Commands | Verified install, dev, build, test, lint, and report commands are not selected because no runnable implementation or dependency manifest exists. | `AGENTS.md` Commands section | +| Commands | Verified install, build, lint, strict typecheck, unit-test, static security-catalog, process-smoke, and aggregate-check commands are exposed by the Makefile. | `Makefile`, `AGENTS.md` Commands section | -The last two rows are deliberate D0 blockers. This spec makes the child backlog -explicit, but it does not claim those blockers are closed. +The issue-backlog row records the repository state when this spec was written. +The command baseline is now active; this spec does not by itself claim that the +remaining D0 evidence gates are closed. ### 1.3 Desired state @@ -934,55 +935,62 @@ availability. | CITATION-AUTH-010 | M2 | HTTP/generated SDK + BotDelivery | | EGRESS-011 | M2 | ModelGateway/Sender spies | | TRACE-REDACTION-012 | M0/M1 | persisted ContextRun/DecisionAudit + log scan | -| CACHE-SCOPE-013 | Conditional from first authorization-sensitive cache activation | Runtime behavior with cache-key mutation suite | | ACTION-SEPARATION-014 | M2 | ActionPlane prepare/perform + Sender spy | | CROSS-ORG-LEARN-015 | M0 | architecture/schema/export gate | -| AUDIENCE-016 | M5 | BotDelivery + Runtime + ActionPlane | -| ACL-PROOF-017 | M1 File; M4 live source | Provider contract + Runtime | -| DELIVERY-EVIDENCE-018 | M2 | authenticated ingress before retrieval | | RELEASE-OWNER-019 | M0 | ContextLearning promote + direct-write negative tests | +These are exactly the fifteen canonical release IDs fixed by +[ADR-0019](../decisions/0019-security-catalog-normalization.md). The former +`AUDIENCE-016` cases remain required under `SCOPE-INTERSECTION-004` plus +`EGRESS-011`; former `ACL-PROOF-017` cases under `INDEX-NOT-AUTHORITY-005` plus +`REVOCATION-006`; and former `DELIVERY-EVIDENCE-018` cases under +`TRANSPORT-UNTRUSTED-008`. This normalization removes duplicate release labels, +not safeguards or tests. IDs are never renumbered or reused. + +The twelve canonical top-level scenarios are `ACCEPT-001` cross-Organization +isolation (including the bidirectional A/B assertions in one parameterized +fixture), `ACCEPT-002` same-Organization Membership isolation, `ACCEPT-003` +Agent ceiling, `ACCEPT-004` request narrowing, `ACCEPT-005` revocation, +`ACCEPT-006` hostile index, `ACCEPT-007` transport injection, `ACCEPT-008` +WorkerLease replay/binding, `ACCEPT-009` source-native ACL, `ACCEPT-010` +citation revocation, `ACCEPT-011` denied/not-found equivalence, and +`ACCEPT-012` Context/Action separation. Historical scenarios 13–22 remain +required derived or parameterized evidence and do not add top-level IDs. + Catalog output is exactly `PASS`, `FAIL`, `NOT_ACTIVE`, or `NOT_APPLICABLE`. Capability coverage is reported separately. An active unexecuted or unmapped invariant is `FAIL`; a required exit accepts only `PASS`. M1 separately requires a composition/behavior check proving that no final Package or `AuthorizedProjection` cache is active as an authorization shortcut. -While that capability is inactive, `CACHE-SCOPE-013` is honestly `NOT_ACTIVE` -and is not a required M1 exit entry. If any authorization-sensitive cache is -later activated, the catalog's preregistered `applicableFrom` makes -`CACHE-SCOPE-013` required and only `PASS` can release it. +`CACHE-SCOPE-013` is a preregistered conditional extension outside the +canonical fifteen. While that capability is inactive, the M1 composition check +must prove the path unreachable; it is not a canonical catalog result. If an +authorization-sensitive final Package or `AuthorizedProjection` cache is later +activated, a prior versioned catalog/schema change must add `CACHE-SCOPE-013`, +its `applicableFrom`, and its cache-key mutation cases. Only `PASS` can release +that activated capability. `NON-ENUMERATION-009` has milestone-scoped proving cases. M1 must PASS -deterministic status, body, error, shape, and count equivalence for missing, -same-Organization denied, and cross-Organization refs. Its timing case has -`applicableFrom: M5`; sample size, effect-size threshold, noise controls, and -uncertainty method are preregistered before execution, and the case must PASS for -E5. - -The planned executable catalog is `eval/catalogs/security-invariants.yaml`; once -implemented, it becomes the single source for reports and generated security -documentation. It uses `REVOCATION-006` as the canonical id and includes -`RELEASE-OWNER-019`; a generator check fails if documentation, tests, and the -catalog disagree. - -Each catalog entry has this machine-validated minimum shape: - -```yaml -- id: TENANT-OWNERSHIP-001 - title: explicit Organization ownership - applicability: - mode: required # required | conditional | not_applicable - applicableFrom: M0 # required for required/conditional - rationale: null # required only for not_applicable - capabilityRef: tenant-isolation - requiredMilestones: [M0] - provingCases: - property: [case-id] - postgres: [case-id] - runtimeOrDelivery: [case-id] - evidenceArtifacts: [artifact-pattern] -``` +deterministic status, body, relevant headers, domain outcome, shape, and count +equivalence for missing, same-Organization denied, and cross-Organization refs; +M1 makes no timing-equivalence claim. Its timing case has `applicableFrom: M5`; +sample size, effect-size threshold, noise controls, and uncertainty method are +preregistered before execution, and the case must PASS for E5. + +The executable machine authority is `eval/catalogs/security-invariants.yaml`. +Its schema is `eval/catalogs/security-catalog.schema.json`, and +`python3 scripts/validate_security_catalog.py` checks the exact count, order, +IDs, shape, and tracked document references. The catalog uses JSON-compatible +YAML so the validator remains standard-library-only and deterministic in +bootstrap and CI, independent of application dependencies. It uses +`REVOCATION-006` and includes `RELEASE-OWNER-019`. + +The schema, rather than a duplicated prose example, owns the exact entry shape. +At minimum it makes identity, purpose, threat/assets, deterministic and hard +oracles, applicability, capability, milestone, evidence status, expected +property/PostgreSQL/runtime-or-delivery evidence, and authority references +machine-required. The runner joins catalog entries to capability activation and current test results, then emits the four-state status. Unknown ids, duplicate ids, a missing @@ -1142,9 +1150,12 @@ their shape is known. Policy Epoch granularity. - **Acceptance:** no P0/P1 cross-document conflict; evidence reports and their digests are pinned; Runtime/Provider/BotDelivery/ActionPlane/Learning test seams - are approved; the canonical invariant catalog normalizes revocation naming and - includes `RELEASE-OWNER-019`; the epoch prototype, impact report, and decision - update is pinned; the publication destination is explicit. + are approved; the canonical invariant catalog contains exactly the fifteen + ADR-0019 IDs, uses `REVOCATION-006`, includes `RELEASE-OWNER-019`, and passes + `python3 scripts/validate_security_catalog.py`; `ACCEPT-001` through + `ACCEPT-012` (including denied/not-found equivalence) retain all derived + evidence; the epoch prototype, impact report, + and decision update is pinned; the publication destination is explicit. - **Rollback:** supersede with a new baseline manifest; never mutate a historical approved digest in place. @@ -1487,12 +1498,16 @@ authorization weakening. | `docs/decisions/0014-curation-snapshot-and-release-ownership.md` | Curation and single release owner. | | `docs/decisions/0015-rls-transaction-context-and-schema-manifest.md` | PostgreSQL roles, context, WorkerLease, and schema audit. | | `docs/decisions/0016-implementation-authority-and-vertical-slice-roadmap.md` | Authority and milestone sequencing. | +| `docs/decisions/0019-security-catalog-normalization.md` | Exact canonical release IDs, absorbed labels, conditional cache extension, and acceptance-scenario counting. | | `docs/security/Test-Architecture-与可验证性设计.md` | Required test surfaces, catalogs, and CI cadence. | | `docs/security/安全负向测试清单.md` | Adversarial cases and expected zero-byte/effect outcomes. | +| `eval/catalogs/security-invariants.yaml` | Machine authority for the fifteen canonical release invariants. | +| `eval/catalogs/security-catalog.schema.json` | Schema enforced by the D0 catalog validator. | +| `scripts/validate_security_catalog.py` | Standard-library validator for catalog order, shape, IDs, and tracked references. | | `docs/agents/prd-contextengine-implementation.md` | Product intent, user stories, and program acceptance decisions. | | `DESIGN-BASELINE.md` | Candidate digest and D0 promotion checklist. | | `PLAN.md` | Public roadmap summary. | -| `AGENTS.md` | Repository safety rails and future commands. | +| `AGENTS.md` | Repository safety rails and verified commands. | Planned implementation directories are created only when their first owning work package begins: `engine/`, `adapters/`, `bot_delivery/`, `action_plane/`, diff --git a/eval/catalogs/security-catalog.schema.json b/eval/catalogs/security-catalog.schema.json new file mode 100644 index 00000000..c6a305bf --- /dev/null +++ b/eval/catalogs/security-catalog.schema.json @@ -0,0 +1,750 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ContextEngine security invariant and acceptance fixture catalog", + "type": "object", + "additionalProperties": false, + "required": [ + "catalogVersion", + "authority", + "hardOracles", + "invariants", + "fixtures" + ], + "properties": { + "catalogVersion": { + "type": "string", + "const": "1.0.0" + }, + "authority": { + "$ref": "#/$defs/authority" + }, + "hardOracles": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + { + "type": "object", + "additionalProperties": false, + "required": ["name", "requiredValue", "veto"], + "properties": { + "name": {"const": "Unauthorized Evidence"}, + "requiredValue": {"type": "integer", "const": 0}, + "veto": {"type": "boolean", "const": true} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["name", "requiredValue", "veto"], + "properties": { + "name": {"const": "wrong-Organization effect"}, + "requiredValue": {"type": "integer", "const": 0}, + "veto": {"type": "boolean", "const": true} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["name", "requiredValue", "veto"], + "properties": { + "name": {"const": "missing-context fallback"}, + "requiredValue": {"type": "integer", "const": 0}, + "veto": {"type": "boolean", "const": true} + } + } + ], + "items": false + }, + "invariants": { + "type": "array", + "minItems": 15, + "maxItems": 15, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/invariant" + } + }, + "fixtures": { + "type": "array", + "minItems": 12, + "maxItems": 12, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/fixture" + } + } + }, + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "stringArray": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "authorityRef": { + "type": "string", + "minLength": 1, + "pattern": "^(#[0-9]+|(?!(?:[A-Za-z]:[/\\\\]|/|https?://|file://))(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._\\-/\u4e00-\u9fff]+(?:#[A-Za-z0-9._\\-/\u4e00-\u9fff]+)?)$" + }, + "documentRef": { + "type": "string", + "minLength": 1, + "pattern": "^(?!(?:[A-Za-z]:[/\\\\]|/|https?://|file://))(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._\\-/\u4e00-\u9fff]+(?:#[A-Za-z0-9._\\-/]+)?$" + }, + "authority": { + "type": "object", + "additionalProperties": false, + "required": ["issueRefs", "documentRefs", "reconciliation"], + "properties": { + "issueRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^#[0-9]+$" + } + }, + "documentRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/documentRef" + } + }, + "reconciliation": { + "$ref": "#/$defs/nonEmptyString" + } + } + }, + "invariantId": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-[0-9]{3}$", + "enum": [ + "TENANT-OWNERSHIP-001", + "TENANT-FK-002", + "RLS-FAIL-CLOSED-003", + "SCOPE-INTERSECTION-004", + "INDEX-NOT-AUTHORITY-005", + "REVOCATION-006", + "WORKER-LEASE-007", + "TRANSPORT-UNTRUSTED-008", + "NON-ENUMERATION-009", + "CITATION-AUTH-010", + "EGRESS-011", + "TRACE-REDACTION-012", + "ACTION-SEPARATION-014", + "CROSS-ORG-LEARN-015", + "RELEASE-OWNER-019" + ] + }, + "fixtureId": { + "type": "string", + "pattern": "^ACCEPT-[0-9]{3}$", + "enum": [ + "ACCEPT-001", + "ACCEPT-002", + "ACCEPT-003", + "ACCEPT-004", + "ACCEPT-005", + "ACCEPT-006", + "ACCEPT-007", + "ACCEPT-008", + "ACCEPT-009", + "ACCEPT-010", + "ACCEPT-011", + "ACCEPT-012" + ] + }, + "threatRef": { + "type": "string", + "pattern": "^TM-(0[1-9]|1[0-8])$" + }, + "assetRef": { + "type": "string", + "pattern": "^A-0[1-8]$" + }, + "hardOracleName": { + "type": "string", + "enum": [ + "Unauthorized Evidence", + "wrong-Organization effect", + "missing-context fallback" + ] + }, + "milestone": { + "type": "string", + "pattern": "^(D0|M[0-9]+|C[0-9]+|P[0-9]+)$" + }, + "applicability": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "applicableFrom", "rationale"], + "properties": { + "mode": { + "type": "string", + "enum": ["required", "conditional", "not_applicable"] + }, + "applicableFrom": { + "type": ["string", "null"], + "minLength": 1, + "pattern": "\\S" + }, + "rationale": { + "type": ["string", "null"], + "minLength": 1, + "pattern": "\\S" + } + }, + "if": { + "properties": { + "mode": {"const": "not_applicable"} + }, + "required": ["mode"] + }, + "then": { + "properties": { + "applicableFrom": {"type": "null"}, + "rationale": {"$ref": "#/$defs/nonEmptyString"} + } + }, + "else": { + "properties": { + "applicableFrom": {"$ref": "#/$defs/nonEmptyString"} + } + } + }, + "expectedEvidence": { + "type": "object", + "additionalProperties": false, + "required": ["property", "postgres", "runtimeOrDelivery"], + "properties": { + "property": {"$ref": "#/$defs/stringArray"}, + "postgres": {"$ref": "#/$defs/stringArray"}, + "runtimeOrDelivery": {"$ref": "#/$defs/stringArray"} + } + }, + "invariant": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "purpose", + "threatRefs", + "protectedAssets", + "deterministicOracle", + "hardOracleRefs", + "applicability", + "capabilityRef", + "requiredMilestones", + "evidenceStatus", + "expectedEvidence", + "authorityRefs" + ], + "properties": { + "id": {"$ref": "#/$defs/invariantId"}, + "title": {"$ref": "#/$defs/nonEmptyString"}, + "purpose": {"$ref": "#/$defs/nonEmptyString"}, + "threatRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/threatRef"} + }, + "protectedAssets": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/assetRef"} + }, + "deterministicOracle": {"$ref": "#/$defs/nonEmptyString"}, + "hardOracleRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/hardOracleName"} + }, + "applicability": {"$ref": "#/$defs/applicability"}, + "capabilityRef": {"$ref": "#/$defs/nonEmptyString"}, + "requiredMilestones": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/milestone"} + }, + "evidenceStatus": { + "type": "string", + "enum": ["accepted", "future_case"] + }, + "expectedEvidence": {"$ref": "#/$defs/expectedEvidence"}, + "authorityRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/authorityRef"} + } + } + }, + "carrier": { + "type": "object", + "additionalProperties": false, + "required": ["statusAtM0", "m0Expectation", "upgradeTrigger"], + "properties": { + "statusAtM0": { + "type": "string", + "enum": ["available", "unavailable", "future"] + }, + "m0Expectation": { + "type": "string", + "enum": ["active_fail_closed", "fail_closed"] + }, + "upgradeTrigger": {"$ref": "#/$defs/nonEmptyString"} + }, + "if": { + "properties": { + "statusAtM0": {"const": "available"} + }, + "required": ["statusAtM0"] + }, + "then": { + "properties": { + "m0Expectation": {"const": "active_fail_closed"} + } + }, + "else": { + "properties": { + "m0Expectation": {"const": "fail_closed"} + } + } + }, + "trustedIdentity": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "actorKind": {"$ref": "#/$defs/nonEmptyString"}, + "serviceActorRef": {"$ref": "#/$defs/nonEmptyString"}, + "organizationRef": {"$ref": "#/$defs/nonEmptyString"}, + "principalRef": {"$ref": "#/$defs/nonEmptyString"}, + "agentRef": {"$ref": "#/$defs/nonEmptyString"}, + "agentVersion": {"type": "integer", "minimum": 0}, + "membershipVersion": {"type": "integer", "minimum": 0}, + "currentPolicyEpoch": {"type": "integer", "minimum": 0}, + "purpose": {"$ref": "#/$defs/nonEmptyString"}, + "requiredAclMode": {"$ref": "#/$defs/nonEmptyString"}, + "capabilityAudience": {"$ref": "#/$defs/nonEmptyString"}, + "authenticationSource": {"$ref": "#/$defs/nonEmptyString"}, + "durableJobRef": {"$ref": "#/$defs/nonEmptyString"}, + "samplePlanRef": {"$ref": "#/$defs/nonEmptyString"}, + "source": {"$ref": "#/$defs/nonEmptyString"}, + "invocations": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/invocationIdentity"} + } + } + }, + "invocationIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["organizationRef", "principalRef", "purpose"], + "properties": { + "organizationRef": {"$ref": "#/$defs/nonEmptyString"}, + "principalRef": {"$ref": "#/$defs/nonEmptyString"}, + "purpose": {"$ref": "#/$defs/nonEmptyString"} + } + }, + "setup": { + "type": "object", + "additionalProperties": false, + "required": ["preconditions", "trustedIdentity"], + "properties": { + "preconditions": {"$ref": "#/$defs/stringArray"}, + "trustedIdentity": {"$ref": "#/$defs/trustedIdentity"} + } + }, + "adversarialMutation": { + "type": "object", + "additionalProperties": false, + "minProperties": 2, + "required": ["kind"], + "properties": { + "kind": {"$ref": "#/$defs/nonEmptyString"}, + "caseRef": {"$ref": "#/$defs/nonEmptyString"}, + "attempts": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/probeAttempt"} + }, + "candidateWasDiscoveredFor": {"$ref": "#/$defs/nonEmptyString"}, + "candidateFields": {"$ref": "#/$defs/stringArray"}, + "currentInvocation": {"$ref": "#/$defs/nonEmptyString"}, + "requestedAgentSources": {"$ref": "#/$defs/stringArray"}, + "signedAgentCeiling": {"$ref": "#/$defs/stringArray"}, + "requestNarrowing": {"$ref": "#/$defs/requestNarrowing"}, + "promptInjectedSourceRefs": {"$ref": "#/$defs/stringArray"}, + "tokenPolicyEpoch": {"type": "integer", "minimum": 0}, + "cachedResourceStillPresent": {"type": "boolean"}, + "orderedCandidateRefs": {"$ref": "#/$defs/stringArray"}, + "candidatePayloadFields": { + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/nonEmptyString"} + }, + "bodyFields": {"$ref": "#/$defs/injectedBodyFields"}, + "replayCount": {"type": "integer", "minimum": 1}, + "mutatedClaim": {"$ref": "#/$defs/mutatedClaim"}, + "retainedNonce": {"$ref": "#/$defs/nonEmptyString"}, + "parameterizedCases": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/parameterizedCase"} + }, + "requestedFallback": {"$ref": "#/$defs/nonEmptyString"}, + "missingCapability": {"$ref": "#/$defs/nonEmptyString"}, + "locator": {"$ref": "#/$defs/nonEmptyString"}, + "originalPolicyEpoch": {"type": "integer", "minimum": 0}, + "attemptedAlternateUse": {"$ref": "#/$defs/nonEmptyString"}, + "probes": {"$ref": "#/$defs/stringArray"}, + "order": {"$ref": "#/$defs/stringArray"}, + "ticketKind": {"$ref": "#/$defs/nonEmptyString"}, + "requestedEffect": {"$ref": "#/$defs/nonEmptyString"}, + "destination": {"$ref": "#/$defs/nonEmptyString"}, + "targetOrganization": {"$ref": "#/$defs/nonEmptyString"} + } + }, + "probeAttempt": { + "type": "object", + "additionalProperties": false, + "required": ["invocation", "target"], + "properties": { + "invocation": {"$ref": "#/$defs/nonEmptyString"}, + "target": {"$ref": "#/$defs/nonEmptyString"} + } + }, + "requestNarrowing": { + "type": "object", + "additionalProperties": false, + "required": ["sourceRefs"], + "properties": { + "sourceRefs": {"$ref": "#/$defs/stringArray"} + } + }, + "injectedBodyFields": { + "type": "object", + "additionalProperties": false, + "required": ["organizationRef", "principalRef", "purpose", "audience", "acl", "rawSql", "bypassAuthorization"], + "properties": { + "organizationRef": {"$ref": "#/$defs/nonEmptyString"}, + "principalRef": {"$ref": "#/$defs/nonEmptyString"}, + "purpose": {"$ref": "#/$defs/nonEmptyString"}, + "audience": {"$ref": "#/$defs/stringArray"}, + "acl": {"$ref": "#/$defs/nonEmptyString"}, + "rawSql": {"$ref": "#/$defs/nonEmptyString"}, + "bypassAuthorization": {"type": "boolean"} + } + }, + "mutatedClaim": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "organizationRef": {"$ref": "#/$defs/nonEmptyString"}, + "jobRef": {"$ref": "#/$defs/nonEmptyString"}, + "sourceRef": {"$ref": "#/$defs/nonEmptyString"}, + "operation": {"$ref": "#/$defs/nonEmptyString"}, + "generation": {"type": "integer", "minimum": 0}, + "nonce": {"$ref": "#/$defs/nonEmptyString"} + } + }, + "parameterizedCase": { + "type": "object", + "additionalProperties": false, + "required": ["id", "mutation", "expectedStatus", "expectedOutcome", "expectedNewDurableEffects", "expectedWrongOrganizationEffects", "expectedContentWorkCalls"], + "properties": { + "id": {"$ref": "#/$defs/nonEmptyString"}, + "claim": {"$ref": "#/$defs/nonEmptyString"}, + "mutation": { + "type": ["string", "integer"], + "minLength": 1, + "pattern": "\\S", + "minimum": 0 + }, + "expectedStatus": {"type": "integer", "minimum": 100, "maximum": 599}, + "expectedOutcome": {"$ref": "#/$defs/nonEmptyString"}, + "expectedNewDurableEffects": {"type": "integer", "const": 0}, + "expectedWrongOrganizationEffects": {"type": "integer", "const": 0}, + "expectedContentWorkCalls": {"type": "integer", "const": 0}, + "activatedOracle": {"$ref": "#/$defs/nonEmptyString"} + } + }, + "operation": { + "type": "object", + "additionalProperties": false, + "minProperties": 2, + "required": ["interface", "request"], + "properties": { + "interface": {"$ref": "#/$defs/nonEmptyString"}, + "request": {"$ref": "#/$defs/nonEmptyString"}, + "count": {"type": "integer", "minimum": 1}, + "observation": {"$ref": "#/$defs/nonEmptyString"}, + "expectedAuthorizedProjection": {"$ref": "#/$defs/stringArray"}, + "expectedEffectiveScope": {"$ref": "#/$defs/stringArray"}, + "comparison": {"$ref": "#/$defs/nonEmptyString"}, + "timing": {"$ref": "#/$defs/nonEmptyString"}, + "requiredTypeFlow": {"$ref": "#/$defs/stringArray"}, + "phase": {"$ref": "#/$defs/nonEmptyString"}, + "durableComparison": {"$ref": "#/$defs/nonEmptyString"}, + "comparisonFields": {"$ref": "#/$defs/stringArray"}, + "normalizationAllowlist": {"$ref": "#/$defs/stringArray"} + } + }, + "externalResponse": { + "type": "object", + "additionalProperties": false, + "minProperties": 2, + "required": ["status"], + "properties": { + "status": {"type": "integer", "minimum": 100, "maximum": 599}, + "code": {"$ref": "#/$defs/nonEmptyString"}, + "body": {"$ref": "#/$defs/responseBody"}, + "sameOutcomeSemanticsForBothAttempts": {"type": "boolean"}, + "authorizedPayloadUtf8": {"$ref": "#/$defs/nonEmptyString"}, + "authorizedPayloadBytes": {"type": "integer", "minimum": 0}, + "privateFieldPresent": {"type": "boolean"}, + "privateResourcePresent": {"type": "boolean"}, + "source2Present": {"type": "boolean"}, + "orgBContentPresent": {"type": "boolean"}, + "fieldNamesEchoed": {"type": "boolean"}, + "leaseClaimsEchoed": {"type": "boolean"}, + "headers": {"$ref": "#/$defs/responseHeaders"}, + "normalizedByteIdenticalAcrossProbes": {"type": "boolean"}, + "timingEqualityClaimed": {"type": "boolean"} + } + }, + "responseBody": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "kind": {"$ref": "#/$defs/nonEmptyString"}, + "gap": {"$ref": "#/$defs/nonEmptyString"}, + "retryable": {"type": "boolean"}, + "package": {"$ref": "#/$defs/emptyContextPackage"}, + "egressGrant": {"$ref": "#/$defs/nonEmptyString"} + } + }, + "emptyContextPackage": { + "type": "object", + "additionalProperties": false, + "required": ["packageId", "packageDigest", "purpose", "audienceDigest", "policyEpoch", "decisionRef", "releaseManifestRef", "retentionPolicyRef", "asOf", "expiresAt", "tokenizerRef", "blocks", "evidence", "gaps", "coverage", "budgetUsage"], + "properties": { + "packageId": {"$ref": "#/$defs/nonEmptyString"}, + "packageDigest": {"$ref": "#/$defs/nonEmptyString"}, + "purpose": {"$ref": "#/$defs/nonEmptyString"}, + "audienceDigest": {"$ref": "#/$defs/nonEmptyString"}, + "policyEpoch": {"$ref": "#/$defs/nonEmptyString"}, + "decisionRef": {"$ref": "#/$defs/nonEmptyString"}, + "releaseManifestRef": {"$ref": "#/$defs/nonEmptyString"}, + "retentionPolicyRef": {"$ref": "#/$defs/nonEmptyString"}, + "asOf": {"$ref": "#/$defs/nonEmptyString"}, + "expiresAt": {"$ref": "#/$defs/nonEmptyString"}, + "tokenizerRef": {"$ref": "#/$defs/nonEmptyString"}, + "blocks": {"type": "array", "maxItems": 0}, + "evidence": {"type": "array", "maxItems": 0}, + "gaps": {"type": "array", "maxItems": 0}, + "coverage": {"$ref": "#/$defs/emptyCoverage"}, + "budgetUsage": {"$ref": "#/$defs/zeroBudgetUsage"} + } + }, + "emptyCoverage": { + "type": "object", + "additionalProperties": false, + "required": ["status", "reason"], + "properties": { + "status": {"const": "empty"}, + "reason": {"const": "no_authorized_evidence"} + } + }, + "zeroBudgetUsage": { + "type": "object", + "additionalProperties": false, + "required": ["tokens", "providerCalls", "costMicrounits", "elapsedMs"], + "properties": { + "tokens": {"type": "integer", "const": 0}, + "providerCalls": {"type": "integer", "const": 0}, + "costMicrounits": {"type": "integer", "const": 0}, + "elapsedMs": {"type": "integer", "const": 0} + } + }, + "responseHeaders": { + "type": "object", + "additionalProperties": false, + "required": ["Content-Type", "Cache-Control", "X-Context-Request-Id"], + "properties": { + "Content-Type": {"const": "application/json"}, + "Cache-Control": {"const": "no-store"}, + "X-Context-Request-Id": {"$ref": "#/$defs/nonEmptyString"} + } + }, + "packageOrError": { + "type": "object", + "additionalProperties": false, + "minProperties": 2, + "required": ["kind"], + "properties": { + "kind": {"$ref": "#/$defs/nonEmptyString"}, + "packageCount": {"type": "integer", "minimum": 0}, + "crossOrganizationResourceDetailCount": {"type": "integer", "minimum": 0}, + "coverageStatus": {"$ref": "#/$defs/nonEmptyString"}, + "coverageReason": {"$ref": "#/$defs/nonEmptyString"}, + "evidenceFields": {"$ref": "#/$defs/stringArray"}, + "deniedFieldCountExposed": {"type": "boolean"}, + "evidenceResourceRefs": {"$ref": "#/$defs/stringArray"}, + "gap": {"$ref": "#/$defs/nonEmptyString"}, + "sourceRefs": {"$ref": "#/$defs/stringArray"}, + "expandedSourceCallCount": {"type": "integer", "minimum": 0}, + "stalePackageReturned": {"type": "boolean"}, + "revokedResourceDetailCount": {"type": "integer", "minimum": 0}, + "evidenceRefs": {"$ref": "#/$defs/stringArray"}, + "deniedCandidateCountExposed": {"type": "boolean"}, + "contextPackageCreated": {"type": "boolean"}, + "trustedContextConstructedFromBody": {"type": "boolean"}, + "reasonVisibleToWorker": {"$ref": "#/$defs/nonEmptyString"}, + "newReceiptCreated": {"type": "boolean"}, + "aclModeUsed": {"type": ["string", "null"], "minLength": 1}, + "weakFallbackCount": {"type": "integer", "minimum": 0}, + "capabilityReportedAsPass": {"type": "boolean"}, + "citationFieldsReturned": {"type": "integer", "minimum": 0}, + "capabilityStatus": {"$ref": "#/$defs/nonEmptyString"}, + "deniedCountExposed": {"type": "boolean"}, + "existenceDetailCount": {"type": "integer", "minimum": 0}, + "actionTicketCreated": {"type": "boolean"}, + "contextTicketConsumed": {"type": "boolean"} + } + }, + "evidenceMetrics": { + "type": "object", + "additionalProperties": false, + "required": ["unauthorizedEvidenceCount", "unauthorizedContentBytes", "missingContextFallbackCount", "outboundBytes"], + "properties": { + "unauthorizedEvidenceCount": {"type": "integer", "minimum": 0, "const": 0}, + "unauthorizedContentBytes": {"type": "integer", "minimum": 0, "const": 0}, + "missingContextFallbackCount": {"type": "integer", "minimum": 0, "const": 0}, + "outboundBytes": {"type": "integer", "minimum": 0} + } + }, + "businessEffectMetrics": { + "type": "object", + "additionalProperties": false, + "required": ["wrongOrganizationEffectCount", "mutationEffectCount", "totalEffectsAfterScenario"], + "properties": { + "wrongOrganizationEffectCount": {"type": "integer", "minimum": 0, "const": 0}, + "mutationEffectCount": {"type": "integer", "minimum": 0}, + "totalEffectsAfterScenario": {"type": "integer", "minimum": 0} + } + }, + "ioMetrics": { + "type": "object", + "additionalProperties": false, + "required": ["providerCalls", "indexCalls", "modelCalls", "actionCalls"], + "properties": { + "providerCalls": {"type": "integer", "minimum": 0}, + "indexCalls": {"type": "integer", "minimum": 0}, + "modelCalls": {"type": "integer", "minimum": 0}, + "actionCalls": {"type": "integer", "minimum": 0} + } + }, + "expected": { + "type": "object", + "additionalProperties": false, + "required": ["externalResponse", "packageOrError", "evidence", "businessEffects", "io"], + "properties": { + "externalResponse": {"$ref": "#/$defs/externalResponse"}, + "packageOrError": {"$ref": "#/$defs/packageOrError"}, + "evidence": {"$ref": "#/$defs/evidenceMetrics"}, + "businessEffects": {"$ref": "#/$defs/businessEffectMetrics"}, + "io": {"$ref": "#/$defs/ioMetrics"} + } + }, + "fixture": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "decisionStatus", + "carrier", + "setup", + "adversarialMutation", + "operation", + "expected", + "invariantRefs", + "authorityRefs" + ], + "properties": { + "id": {"$ref": "#/$defs/fixtureId"}, + "title": {"$ref": "#/$defs/nonEmptyString"}, + "decisionStatus": { + "type": "string", + "enum": ["accepted", "future_case"] + }, + "carrier": {"$ref": "#/$defs/carrier"}, + "setup": {"$ref": "#/$defs/setup"}, + "adversarialMutation": {"$ref": "#/$defs/adversarialMutation"}, + "operation": {"$ref": "#/$defs/operation"}, + "expected": {"$ref": "#/$defs/expected"}, + "invariantRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/invariantId"} + }, + "authorityRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/authorityRef"} + } + }, + "if": { + "properties": { + "carrier": { + "properties": { + "statusAtM0": {"enum": ["unavailable", "future"]} + }, + "required": ["statusAtM0"] + } + }, + "required": ["carrier"] + }, + "then": { + "properties": { + "expected": { + "properties": { + "io": { + "properties": { + "providerCalls": {"const": 0}, + "indexCalls": {"const": 0}, + "modelCalls": {"const": 0}, + "actionCalls": {"const": 0} + } + } + } + } + } + } + } + } +} diff --git a/eval/catalogs/security-invariants.yaml b/eval/catalogs/security-invariants.yaml new file mode 100644 index 00000000..09298cb9 --- /dev/null +++ b/eval/catalogs/security-invariants.yaml @@ -0,0 +1,1124 @@ +{ + "catalogVersion": "1.0.0", + "authority": { + "issueRefs": [ + "#2", + "#5" + ], + "documentRefs": [ + "README.md", + "docs/agents/prd-contextengine-implementation.md", + "docs/design/2026-07-18-context-engine-implementation-design.md", + "docs/security/context-engine-threat-model.md", + "docs/security/Test-Architecture-与可验证性设计.md", + "docs/security/安全负向测试清单.md", + "docs/decisions/0019-security-catalog-normalization.md" + ], + "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; carrier status and the explicit M0 oracle preserve the distinction between an accepted decision and an activated capability." + }, + "hardOracles": [ + { + "name": "Unauthorized Evidence", + "requiredValue": 0, + "veto": true + }, + { + "name": "wrong-Organization effect", + "requiredValue": 0, + "veto": true + }, + { + "name": "missing-context fallback", + "requiredValue": 0, + "veto": true + } + ], + "invariants": [ + { + "id": "TENANT-OWNERSHIP-001", + "title": "Every tenant-owned object has explicit Organization ownership", + "purpose": "Prevent orphaned or ambiguously owned rows, blobs, index records, jobs, traces, and Packages from entering a tenant path.", + "threatRefs": ["TM-02", "TM-08"], + "protectedAssets": ["A-01", "A-03", "A-08"], + "deterministicOracle": "Pass only when every classified tenant object has one Organization owner, missing-owner creation is rejected, and the orphan count is exactly 0; fail on any accepted missing owner or ambiguous ownership chain.", + "hardOracleRefs": ["Unauthorized Evidence", "wrong-Organization effect", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M0", + "rationale": null + }, + "capabilityRef": "organization-owned-storage", + "requiredMilestones": ["M0", "M1"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-TENANT-OWNERSHIP-001"], + "postgres": ["PG-TENANT-OWNERSHIP-001", "DB-008"], + "runtimeOrDelivery": ["RUNTIME-TENANT-OWNERSHIP-001"] + }, + "authorityRefs": [ + "docs/security/context-engine-threat-model.md#2-protected-assets", + "docs/security/Test-Architecture-与可验证性设计.md#4-security-invariant-catalog", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "TENANT-FK-002", + "title": "Tenant children cannot reference another Organization's parent", + "purpose": "Make cross-Organization object graphs structurally unrepresentable even when application filters or identifiers are wrong.", + "threatRefs": ["TM-02"], + "protectedAssets": ["A-01", "A-03"], + "deterministicOracle": "Pass only when every attempted cross-Organization child-to-parent write is rejected and creates exactly 0 rows; fail if any cross-Organization reference commits.", + "hardOracleRefs": ["Unauthorized Evidence", "wrong-Organization effect"], + "applicability": { + "mode": "required", + "applicableFrom": "M0", + "rationale": null + }, + "capabilityRef": "composite-tenant-ownership", + "requiredMilestones": ["M0", "M1"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-TENANT-FK-002"], + "postgres": ["PG-TENANT-FK-002", "DB-003"], + "runtimeOrDelivery": ["RUNTIME-TENANT-FK-002"] + }, + "authorityRefs": [ + "docs/security/context-engine-threat-model.md#6-threat-register", + "docs/security/安全负向测试清单.md#3-databaseindexcache-与-blob", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "RLS-FAIL-CLOSED-003", + "title": "Missing or invalid tenant transaction context fails closed", + "purpose": "Ensure a non-owner database session never falls back to a default tenant or inherited pool context.", + "threatRefs": ["TM-01", "TM-02"], + "protectedAssets": ["A-01", "A-02", "A-06"], + "deterministicOracle": "Pass only when non-owner SELECT returns 0 tenant rows or a generic error, every tenant write errors, and missing-context fallback is exactly 0; fail if any tenant datum is read or written without the complete transaction-local context.", + "hardOracleRefs": ["Unauthorized Evidence", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M0", + "rationale": null + }, + "capabilityRef": "non-owner-force-rls", + "requiredMilestones": ["M0"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-RLS-FAIL-CLOSED-003"], + "postgres": ["PG-RLS-FAIL-CLOSED-003", "DB-001", "DB-006", "DB-009"], + "runtimeOrDelivery": ["AUTH-003", "RUNTIME-RLS-FAIL-CLOSED-003"] + }, + "authorityRefs": [ + "docs/security/context-engine-threat-model.md#3-trust-boundaries", + "docs/security/Test-Architecture-与可验证性设计.md#6-postgresrls-integration-harness", + "docs/security/安全负向测试清单.md#3-databaseindexcache-与-blob" + ] + }, + { + "id": "SCOPE-INTERSECTION-004", + "title": "Agent and request scope can only narrow trusted scope", + "purpose": "Keep EffectiveScope equal to the complete trusted authorization intersection while treating optional RequestNarrowing only as an additional restriction.", + "threatRefs": ["TM-01", "TM-11"], + "protectedAssets": ["A-01", "A-02", "A-04"], + "deterministicOracle": "Pass only when every generated Agent ceiling, request filter, and audience intersection produces a result set that is a subset of the trusted Principal scope; fail if adding an untrusted operand introduces any Resource or field, or if a missing required trusted operand yields a nonempty result.", + "hardOracleRefs": ["Unauthorized Evidence", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M0", + "rationale": null + }, + "capabilityRef": "effective-scope-intersection", + "requiredMilestones": ["M0", "M1", "M5"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-SCOPE-INTERSECTION-004"], + "postgres": ["PG-SCOPE-INTERSECTION-004", "DB-010"], + "runtimeOrDelivery": ["AUTH-006", "AUTH-007", "AUTH-010", "AUTH-011", "RUN-014"] + }, + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#solution", + "docs/security/Test-Architecture-与可验证性设计.md#5-property-based-authorization-tests", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "INDEX-NOT-AUTHORITY-005", + "title": "Candidate discovery is never authorization", + "purpose": "Keep CandidateRef content-free and require every candidate or expansion to cross the sealed AuthorizationKernel before any content-bearing consumer.", + "threatRefs": ["TM-03", "TM-04", "TM-09"], + "protectedAssets": ["A-01", "A-04", "A-07"], + "deterministicOracle": "Pass only when each raw candidate follows CandidateRef to AuthorizationKernel to AuthorizedProjection, denied projections and denied content bytes are exactly 0 at hydration, rerank, assembly, model, Package, and ContextRun seams; fail if index or cache output alone reaches any content-bearing consumer.", + "hardOracleRefs": ["Unauthorized Evidence"], + "applicability": { + "mode": "required", + "applicableFrom": "M0", + "rationale": null + }, + "capabilityRef": "sealed-authorization-projection", + "requiredMilestones": ["M0", "M1", "M3"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-INDEX-NOT-AUTHORITY-005"], + "postgres": ["PG-INDEX-NOT-AUTHORITY-005", "IDX-001", "IDX-002"], + "runtimeOrDelivery": ["RUN-002", "RUN-003", "RUN-013", "PROV-010", "PROV-013", "PROV-014", "PROV-015", "PROV-018", "PROV-019", "PROV-020"] + }, + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#3-runtime-security-pipeline", + "docs/security/context-engine-threat-model.md#6-threat-register", + "docs/security/安全负向测试清单.md#6-runtimeassemblycitation-与-egress" + ] + }, + { + "id": "REVOCATION-006", + "title": "Observed revocation invalidates the next controlled operation", + "purpose": "Prevent stale cache, index, continuation, citation, ticket, or ACL snapshots from preserving future access after Policy Epoch or source evidence changes.", + "threatRefs": ["TM-05", "TM-06", "TM-14"], + "protectedAssets": ["A-01", "A-04", "A-05"], + "deterministicOracle": "Pass only when the first controlled operation after an observed revoke returns 0 revoked Evidence and 0 revoked content bytes without relying on asynchronous cleanup; fail if any stale decision, capability, cache, or strong-to-weak ACL fallback restores visibility.", + "hardOracleRefs": ["Unauthorized Evidence", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M1", + "rationale": null + }, + "capabilityRef": "policy-epoch-revocation", + "requiredMilestones": ["M1", "M2"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-REVOCATION-006"], + "postgres": ["PG-REVOCATION-006", "CACHE-002", "BLOB-002"], + "runtimeOrDelivery": ["RUN-006", "RUN-011", "CITE-002", "PROV-013", "PROV-014", "PROV-015", "PROV-018", "PROV-019", "PROV-020"] + }, + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#44-revocation-linearization", + "docs/security/context-engine-threat-model.md#6-threat-register", + "docs/security/安全负向测试清单.md#5-providerfederation-与-source-native-acl" + ] + }, + { + "id": "WORKER-LEASE-007", + "title": "Worker authority is exact-job, least-privilege, and one-shot", + "purpose": "Prevent worker user impersonation and authority replay across Organizations, jobs, sources, operations, revisions, workloads, or generations.", + "threatRefs": ["TM-07", "TM-08"], + "protectedAssets": ["A-05", "A-06", "A-08"], + "deterministicOracle": "Pass only when mutation of any registered WorkerLease binding, stale generation, expiry, replay, or UserActor impersonation causes exactly 0 new durable mutations and 0 wrong-Organization effects; fail if a mismatched lease changes durable state.", + "hardOracleRefs": ["wrong-Organization effect", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M1", + "rationale": null + }, + "capabilityRef": "signed-worker-lease", + "requiredMilestones": ["M1", "M3"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-WORKER-LEASE-007"], + "postgres": ["PG-WORKER-LEASE-007", "DB-011", "JOB-001", "JOB-005"], + "runtimeOrDelivery": ["WORKER-LEASE-REPLAY-007"] + }, + "authorityRefs": [ + "docs/security/context-engine-threat-model.md#3-trust-boundaries", + "docs/security/Test-Architecture-与可验证性设计.md#73-queueworker-contract", + "docs/security/安全负向测试清单.md#4-workeroutbox-与-publication" + ] + }, + { + "id": "TRANSPORT-UNTRUSTED-008", + "title": "Untrusted transport cannot author trusted invocation facts", + "purpose": "Bind Organization, Principal, Membership, purpose, audience, and delivery facts only from authenticated ingress or a redeemed request-bound DeliveryEvidenceRef.", + "threatRefs": ["TM-01", "TM-10"], + "protectedAssets": ["A-02", "A-04", "A-05"], + "deterministicOracle": "Pass only when every caller-authored trusted field or invalid DeliveryEvidenceRef is rejected before provider, index, model, action, or Package work, with all four call counts and missing-context fallback exactly 0; fail if a body or replayed reference influences trusted context.", + "hardOracleRefs": ["Unauthorized Evidence", "wrong-Organization effect", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M1", + "rationale": null + }, + "capabilityRef": "closed-trusted-ingress", + "requiredMilestones": ["M1", "M2"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-TRANSPORT-UNTRUSTED-008"], + "postgres": ["PG-TRANSPORT-UNTRUSTED-008"], + "runtimeOrDelivery": ["AUTH-002", "AUTH-009", "DELIV-001", "DELIV-002", "DELIV-003", "DELIV-004", "RUN-012"] + }, + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#21-contextcontrol", + "docs/security/context-engine-threat-model.md#3-trust-boundaries", + "docs/security/Test-Architecture-与可验证性设计.md#74-transport-contract" + ] + }, + { + "id": "NON-ENUMERATION-009", + "title": "Denied and nonexistent objects are externally equivalent", + "purpose": "Prevent hidden Resource existence, counts, names, and denial branches from becoming a caller-visible side channel.", + "threatRefs": ["TM-02", "TM-18"], + "protectedAssets": ["A-01", "A-02", "A-07"], + "deterministicOracle": "Pass only when denied and missing probes have byte-identical status, body, headers, and domain outcome after documented per-run fields are normalized; fail on any existence-dependent external difference or leaked denied count. Statistical timing equality is not claimed before its separately preregistered M5 gate.", + "hardOracleRefs": ["Unauthorized Evidence"], + "applicability": { + "mode": "required", + "applicableFrom": "M1", + "rationale": null + }, + "capabilityRef": "non-enumerating-resolution", + "requiredMilestones": ["M1", "M5"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-NON-ENUMERATION-009"], + "postgres": ["PG-NON-ENUMERATION-009", "BLOB-001"], + "runtimeOrDelivery": ["AUTH-008", "RUN-001", "ENUM-001", "ENUM-002"] + }, + "authorityRefs": [ + "docs/security/context-engine-threat-model.md#6-threat-register", + "docs/security/Test-Architecture-与可验证性设计.md#9-runtime-behavior-tests", + "docs/security/安全负向测试清单.md#7-tracedebugnon-enumeration-与-learning" + ] + }, + { + "id": "CITATION-AUTH-010", + "title": "Citation opens reauthorize and are not bearer capabilities", + "purpose": "Keep CitationOpenRef separate from one-shot ContinuationToken and require current opener, audience, source, and policy authorization on every open.", + "threatRefs": ["TM-06", "TM-14"], + "protectedAssets": ["A-01", "A-04", "A-05"], + "deterministicOracle": "Pass only when a wrong opener, revoked grant, tampered locator, or token-kind swap yields exactly 0 fields, 0 source bytes, and a generic unavailable response; fail if the reference itself restores authority.", + "hardOracleRefs": ["Unauthorized Evidence", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M2", + "rationale": null + }, + "capabilityRef": "citation-open-reauthorization", + "requiredMilestones": ["M2", "M3"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-CITATION-AUTH-010"], + "postgres": ["PG-CITATION-AUTH-010", "BLOB-002"], + "runtimeOrDelivery": ["CITE-001", "CITE-002", "CITE-003", "CITE-004"] + }, + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#53-tokens-and-locators", + "docs/security/context-engine-threat-model.md#6-threat-register", + "docs/security/安全负向测试清单.md#6-runtimeassemblycitation-与-egress" + ] + }, + { + "id": "EGRESS-011", + "title": "Delivery egress is package-, audience-, and grant-bound", + "purpose": "Ensure model and sender payloads derive only from one current audience-bound ContextPackage and a matching EgressGrant, with send-time audience revalidation.", + "threatRefs": ["TM-10", "TM-11", "TM-12", "TM-13"], + "protectedAssets": ["A-02", "A-04", "A-05"], + "deterministicOracle": "Pass only when every sensitivity, purpose, provider, region, audience, digest, or snapshot mismatch produces exactly 0 model or sender payload bytes and 0 wrong-Organization effects; fail on any payload or effect without the exact current grant.", + "hardOracleRefs": ["Unauthorized Evidence", "wrong-Organization effect", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M2", + "rationale": null + }, + "capabilityRef": "delivery-egress-grant", + "requiredMilestones": ["M2", "M5"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-EGRESS-011"], + "postgres": ["PG-EGRESS-011"], + "runtimeOrDelivery": ["EGR-001", "EGR-003", "EGR-004", "EGR-005", "EGR-006", "RUN-014", "RUN-015"] + }, + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#5-delivery-audience-egress-and-capability-taxonomy", + "docs/security/context-engine-threat-model.md#3-trust-boundaries", + "docs/security/Test-Architecture-与可验证性设计.md#75-egress-与-action-contract" + ] + }, + { + "id": "TRACE-REDACTION-012", + "title": "Tenant-visible runs contain authorized lineage only", + "purpose": "Keep denied details and secrets out of ContextRun, logs, metrics, debug output, evaluation, and Learning while retaining only restricted redacted DecisionAudit categories and digests.", + "threatRefs": ["TM-15"], + "protectedAssets": ["A-01", "A-06", "A-07"], + "deterministicOracle": "Pass only when tenant-visible and Learning-safe records contain exactly 0 raw denied bodies, denied names, secret values, or denied counts, and restricted audit contains only approved opaque references, digests, and categories; fail on any forbidden match.", + "hardOracleRefs": ["Unauthorized Evidence"], + "applicability": { + "mode": "required", + "applicableFrom": "M0", + "rationale": null + }, + "capabilityRef": "authorized-only-observability", + "requiredMilestones": ["M0", "M1"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-TRACE-REDACTION-012"], + "postgres": ["PG-TRACE-REDACTION-012", "OBS-004", "OBS-005"], + "runtimeOrDelivery": ["OBS-001", "OBS-002", "OBS-003"] + }, + "authorityRefs": [ + "docs/security/context-engine-threat-model.md#6-threat-register", + "docs/security/Test-Architecture-与可验证性设计.md#11-contract-snapshots-与-schema-evolution", + "docs/security/安全负向测试清单.md#7-tracedebugnon-enumeration-与-learning" + ] + }, + { + "id": "ACTION-SEPARATION-014", + "title": "Context authority never grants external-effect authority", + "purpose": "Require ActionPlane prepare then perform with a distinct Organization-, audience-, effect-, destination-, payload-, epoch-, and idempotency-bound one-shot ActionTicket for every effect.", + "threatRefs": ["TM-13"], + "protectedAssets": ["A-04", "A-05", "A-08"], + "deterministicOracle": "Pass only when bypassed prepare, wrong capability class, cross-effect reuse, binding mutation, and replay each add exactly 0 effects and wrong-Organization effect is exactly 0; fail if ContextAccessTicket or any mismatched ActionTicket causes a new effect.", + "hardOracleRefs": ["Unauthorized Evidence", "wrong-Organization effect", "missing-context fallback"], + "applicability": { + "mode": "required", + "applicableFrom": "M2", + "rationale": null + }, + "capabilityRef": "action-plane-one-shot-ticket", + "requiredMilestones": ["M2"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-ACTION-SEPARATION-014"], + "postgres": ["PG-ACTION-SEPARATION-014"], + "runtimeOrDelivery": ["ACTION-001", "ACTION-002", "ACTION-003", "ACTION-004", "ACTION-005", "ACTION-006", "ACTION-007", "ACTION-008", "ACTION-009"] + }, + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#25-actionplane", + "docs/security/context-engine-threat-model.md#6-threat-register", + "docs/security/Test-Architecture-与可验证性设计.md#75-egress-与-action-contract" + ] + }, + { + "id": "CROSS-ORG-LEARN-015", + "title": "V1 Learning contains no raw cross-Organization artifacts", + "purpose": "Prevent feedback, evaluation, exports, and global artifacts from turning authorized tenant content or denied traces into a cross-tenant channel.", + "threatRefs": ["TM-15", "TM-16"], + "protectedAssets": ["A-01", "A-03", "A-07"], + "deterministicOracle": "Pass only when cross-Organization feedback references are rejected, global artifacts contain exactly 0 raw tenant references, and no artifact is produced without the declared opt-in and aggregation gate; fail on any raw cross-Organization lineage.", + "hardOracleRefs": ["Unauthorized Evidence", "wrong-Organization effect"], + "applicability": { + "mode": "required", + "applicableFrom": "M0", + "rationale": null + }, + "capabilityRef": "organization-scoped-learning", + "requiredMilestones": ["M0", "M3"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-CROSS-ORG-LEARN-015"], + "postgres": ["PG-CROSS-ORG-LEARN-015", "LEARN-001"], + "runtimeOrDelivery": ["LEARN-002", "LEARN-003"] + }, + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#out-of-scope", + "docs/security/context-engine-threat-model.md#7-explicit-assumptions-and-non-goals", + "docs/security/安全负向测试清单.md#7-tracedebugnon-enumeration-与-learning" + ] + }, + { + "id": "RELEASE-OWNER-019", + "title": "ContextLearning promotion is the only release activation authority", + "purpose": "Prevent Control, migration, bootstrap, Curation, Runtime, or evaluator paths from directly changing the active ReleaseManifest, including initial activation and rollback.", + "threatRefs": ["TM-16"], + "protectedAssets": ["A-03", "A-07", "A-08"], + "deterministicOracle": "Pass only when every direct active-pointer attempt outside release-operator-authorized ContextLearning.promote changes exactly 0 pointers and 0 publication state, while initial and rollback selections require the same audited promote path; fail on any second publication authority.", + "hardOracleRefs": ["Unauthorized Evidence", "wrong-Organization effect"], + "applicability": { + "mode": "required", + "applicableFrom": "M0", + "rationale": null + }, + "capabilityRef": "governed-release-promotion", + "requiredMilestones": ["M0", "M3"], + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": ["PROP-RELEASE-OWNER-019"], + "postgres": ["PG-RELEASE-OWNER-019", "LEARN-006", "LEARN-007"], + "runtimeOrDelivery": ["LEARN-004", "LEARN-008", "LEARN-009"] + }, + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#63-release-ownership", + "docs/security/context-engine-threat-model.md#3-trust-boundaries", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + } + ], + "fixtures": [ + { + "id": "ACCEPT-001", + "title": "Bidirectional cross-Organization isolation", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issues #8 and #13 upgrade this authority fixture with the real non-owner ownership path and sealed exact-authorization Runtime path." + }, + "setup": { + "preconditions": [ + "Organization org-a owns resource shared-name/a-secret and active Membership member-a.", + "Organization org-b owns a different resource shared-name/b-secret and active Membership member-b.", + "The two opaque Resource identifiers are known to the opposing caller, but neither Membership has a cross-Organization grant." + ], + "trustedIdentity": { + "invocations": [ + {"organizationRef": "org-a", "principalRef": "member-a", "purpose": "context.answer"}, + {"organizationRef": "org-b", "principalRef": "member-b", "purpose": "context.answer"} + ], + "source": "authenticated ingress; callers do not author these values" + } + }, + "adversarialMutation": { + "kind": "cross_organization_resource_probe", + "attempts": [ + {"invocation": "member-a", "target": "shared-name/b-secret"}, + {"invocation": "member-b", "target": "shared-name/a-secret"} + ] + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "Acquire", + "count": 2, + "observation": "Compare the two complete responses without reading restricted DecisionAudit." + }, + "expected": { + "externalResponse": { + "status": 200, + "body": { + "kind": "resolved", + "package": { + "packageId": "opaque-package-ref", + "packageDigest": "sha256-package-digest", + "purpose": "context.answer", + "audienceDigest": "audience-bound-digest", + "policyEpoch": "current-policy-epoch", + "decisionRef": "opaque-decision-ref", + "releaseManifestRef": "active-release-manifest-ref", + "retentionPolicyRef": "active-retention-policy-ref", + "asOf": "current-rfc3339-time", + "expiresAt": "bounded-rfc3339-expiry", + "tokenizerRef": "active-tokenizer-ref", + "blocks": [], + "evidence": [], + "gaps": [], + "coverage": {"status": "empty", "reason": "no_authorized_evidence"}, + "budgetUsage": {"tokens": 0, "providerCalls": 0, "costMicrounits": 0, "elapsedMs": 0} + }, + "egressGrant": "opaque-matching-egress-grant" + }, + "sameOutcomeSemanticsForBothAttempts": true + }, + "packageOrError": {"kind": "ContextPackage", "packageCount": 2, "coverageStatus": "empty", "coverageReason": "no_authorized_evidence", "crossOrganizationResourceDetailCount": 0}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 0}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 0, "indexCalls": 0, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["TENANT-OWNERSHIP-001", "TENANT-FK-002", "RLS-FAIL-CLOSED-003", "NON-ENUMERATION-009"], + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#user-stories", + "docs/security/Test-Architecture-与可验证性设计.md#13-v1-acceptance-scenarios", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-002", + "title": "Same-Organization Membership field differences", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issue #11 upgrades this authority fixture with current Membership validation before content I/O." + }, + "setup": { + "preconditions": [ + "Organization org-a has active Membership member-full with fields status and private_note.", + "The same Organization has active Membership member-limited with field status only.", + "Resource ticket-1 contains ASCII values status=open and private_note=secret." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-limited", + "membershipVersion": 7, + "purpose": "context.answer" + } + }, + "adversarialMutation": { + "kind": "broader_membership_candidate_reuse", + "candidateWasDiscoveredFor": "member-full", + "candidateFields": ["status", "private_note"], + "currentInvocation": "member-limited" + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "Acquire ticket-1", + "expectedAuthorizedProjection": ["status"] + }, + "expected": { + "externalResponse": {"status": 200, "authorizedPayloadUtf8": "status=open", "authorizedPayloadBytes": 11, "privateFieldPresent": false}, + "packageOrError": {"kind": "ContextPackage", "evidenceFields": ["status"], "deniedFieldCountExposed": false}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 11}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 1, "indexCalls": 1, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["SCOPE-INTERSECTION-004", "INDEX-NOT-AUTHORITY-005", "TRACE-REDACTION-012"], + "authorityRefs": [ + "docs/security/安全负向测试清单.md#9-minimal-release-matrix", + "docs/security/安全负向测试清单.md#2-identitytenant-与-delegation", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-003", + "title": "Agent delegation ceiling cannot expand Principal scope", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issue #12 upgrades this authority fixture with the production Agent ceiling and request-narrowing implementation." + }, + "setup": { + "preconditions": [ + "Active member-a may read resource public-1 and private-1.", + "Agent agent-narrow is owned by org-a and delegates only public-1.", + "Both resources appear as content-free candidates for the same question." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "agentRef": "agent-narrow", + "agentVersion": 3, + "purpose": "context.answer" + } + }, + "adversarialMutation": { + "kind": "agent_scope_expansion", + "requestedAgentSources": ["public-1", "private-1"], + "signedAgentCeiling": ["public-1"] + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "Acquire both identifiers", + "expectedEffectiveScope": ["public-1"] + }, + "expected": { + "externalResponse": {"status": 200, "authorizedPayloadUtf8": "public", "authorizedPayloadBytes": 6, "privateResourcePresent": false}, + "packageOrError": {"kind": "ContextPackage", "evidenceResourceRefs": ["public-1"], "gap": "generic_scope_restricted"}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 6}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 1, "indexCalls": 1, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["SCOPE-INTERSECTION-004", "INDEX-NOT-AUTHORITY-005"], + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#solution", + "docs/security/Test-Architecture-与可验证性设计.md#5-property-based-authorization-tests", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-004", + "title": "Caller request narrowing is monotonic", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issue #12 upgrades this authority fixture with production parsing of the closed RequestNarrowing contract." + }, + "setup": { + "preconditions": [ + "member-a and agent-a are authorized for source-1 and source-2.", + "The trusted purpose policy permits both sources.", + "The request explicitly narrows acquisition to source-1." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "agentRef": "agent-a", + "purpose": "context.answer" + } + }, + "adversarialMutation": { + "kind": "prompt_controlled_filter_expansion", + "requestNarrowing": {"sourceRefs": ["source-1"]}, + "promptInjectedSourceRefs": ["source-1", "source-2"] + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "Acquire with explicit source-1 narrowing", + "comparison": "Result set must be a subset of the same invocation without RequestNarrowing." + }, + "expected": { + "externalResponse": {"status": 200, "authorizedPayloadUtf8": "source-one", "authorizedPayloadBytes": 10, "source2Present": false}, + "packageOrError": {"kind": "ContextPackage", "sourceRefs": ["source-1"], "expandedSourceCallCount": 0}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 10}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 1, "indexCalls": 1, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["SCOPE-INTERSECTION-004", "TRANSPORT-UNTRUSTED-008"], + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#implementation-decisions", + "docs/security/安全负向测试清单.md#2-identitytenant-与-delegation", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-005", + "title": "Unavailable stale continuation fails closed before content I/O at M0", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "future", + "m0Expectation": "fail_closed", + "upgradeTrigger": "Issue #16 activates the M0 unavailable-capability refusal without redeeming a continuation; issue #15 separately proves next-request Acquire revocation, and the future real-continuation owner upgrades this fixture only when continuation issuance and redemption exist." + }, + "setup": { + "preconditions": [ + "An opaque caller value claims to be a continuation bound to org-a, membership version 4, and Policy Epoch 8.", + "The authoritative grant is revoked and Policy Epoch is synchronously advanced to 9.", + "M0 does not issue or redeem real continuations; stale candidate and Package-shaped test data remain physically present behind instrumented twins." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "membershipVersion": 5, + "currentPolicyEpoch": 9, + "purpose": "context.answer" + } + }, + "adversarialMutation": { + "kind": "stale_continuation_replay", + "tokenPolicyEpoch": 8, + "cachedResourceStillPresent": true + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "Continue with the epoch-8 token", + "timing": "first controlled operation after observed revocation" + }, + "expected": { + "externalResponse": {"status": 200, "body": {"kind": "request_not_available", "retryable": false}}, + "packageOrError": {"kind": "request_not_available", "stalePackageReturned": false, "revokedResourceDetailCount": 0}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 0}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 0, "indexCalls": 0, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["REVOCATION-006", "CITATION-AUTH-010", "TRACE-REDACTION-012"], + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#implementation-decisions", + "docs/security/Test-Architecture-与可验证性设计.md#9-runtime-behavior-tests", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-006", + "title": "Hostile index candidate cannot bypass exact authorization", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issue #13 upgrades this authority fixture through the sealed CandidateRef to AuthorizationKernel to AuthorizedProjection Runtime path." + }, + "setup": { + "preconditions": [ + "member-a may read candidate-a whose authorized ASCII body is A-safe.", + "candidate-b belongs to org-b and has no grant for member-a.", + "The retrieval seam is configured to return content-free candidate-b ranked before content-free candidate-a." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "purpose": "context.answer" + } + }, + "adversarialMutation": { + "kind": "cross_organization_candidate_injection", + "orderedCandidateRefs": ["candidate-b", "candidate-a"], + "candidatePayloadFields": [] + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "Acquire", + "requiredTypeFlow": ["CandidateRef", "AuthorizationKernel", "AuthorizedProjection"] + }, + "expected": { + "externalResponse": {"status": 200, "authorizedPayloadUtf8": "A-safe", "authorizedPayloadBytes": 6, "orgBContentPresent": false}, + "packageOrError": {"kind": "ContextPackage", "evidenceRefs": ["candidate-a"], "deniedCandidateCountExposed": false}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 6}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 1, "indexCalls": 1, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["TENANT-OWNERSHIP-001", "INDEX-NOT-AUTHORITY-005", "TRACE-REDACTION-012"], + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#3-runtime-security-pipeline", + "docs/security/安全负向测试清单.md#6-runtimeassemblycitation-与-egress", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-007", + "title": "Transport injection cannot manufacture trusted context", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issue #9 upgrades this authority fixture with production HTTP authentication binding and a closed request schema." + }, + "setup": { + "preconditions": [ + "The authenticated session is bound to org-a and member-a.", + "The HTTP Acquire schema accepts only the closed untrusted request fields.", + "The transport matrix also has an opaque DeliveryEvidenceRef bound to bot-service-a, resolve request req-a, org-a, member-a, destination conversation-a, purpose context.answer, audience digest audience-a, policy epoch 4, issued-at, expiry, and nonce; no raw trusted claim is present in the body." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "authenticationSource": "server session" + } + }, + "adversarialMutation": { + "kind": "parameterized_transport_trust_attack", + "bodyFields": { + "organizationRef": "org-b", + "principalRef": "member-b", + "purpose": "admin.export", + "audience": ["member-b"], + "acl": "allow-all", + "rawSql": "select *", + "bypassAuthorization": true + }, + "parameterizedCases": [ + {"id": "BODY-INJECTION", "mutation": "Inject Organization, Principal, purpose, audience, ACL, raw SQL, and bypass fields into the closed request body.", "expectedStatus": 422, "expectedOutcome": "invalid_request", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "DELIV-001", "mutation": "Forge or tamper with the opaque DeliveryEvidenceRef.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "DELIV-002", "mutation": "Replay the valid reference under resolve request req-b.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "DELIV-003", "mutation": "Redeem from bot-service-b or destination conversation-b.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "DELIV-004", "mutation": "Redeem after expiry or reuse the already redeemed reference outside the identical authenticated retry.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0} + ] + }, + "operation": { + "interface": "HTTP POST /resolve", + "request": "Execute each body/ref mutation independently", + "phase": "closed-schema decoding or DeliveryEvidenceRef redemption before trusted context construction and all content work" + }, + "expected": { + "externalResponse": {"status": 422, "code": "invalid_request", "fieldNamesEchoed": false}, + "packageOrError": {"kind": "error", "contextPackageCreated": false, "trustedContextConstructedFromBody": false}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 0}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 0, "indexCalls": 0, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["RLS-FAIL-CLOSED-003", "TRANSPORT-UNTRUSTED-008", "EGRESS-011"], + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#testing-decisions", + "docs/security/Test-Architecture-与可验证性设计.md#74-transport-contract", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-008", + "title": "WorkerLease replay and binding mutation change no durable state", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issue #17 upgrades this authority fixture with signed WorkerLease verification against the current durable job row and one-shot ledger." + }, + "setup": { + "preconditions": [ + "Registered ServiceActor worker-1 completed job-a generation 2 once with nonce n-1.", + "The durable one-shot receipt records one legitimate mutation for org-a/source-a.", + "The current durable job row binds org-a, job-a, ingest, source-a, resource-a, revision-a, worker-1/workload-a, epoch 4, audience-a, idempotency idem-a, generation 2, issued-at, expiry, and nonce n-1; the signed lease has the same complete binding." + ], + "trustedIdentity": { + "actorKind": "ServiceActor", + "serviceActorRef": "worker-1", + "organizationRef": "org-a", + "durableJobRef": "job-a" + } + }, + "adversarialMutation": { + "kind": "parameterized_worker_lease_binding_and_replay", + "replayCount": 1, + "mutatedClaim": {"organizationRef": "org-b"}, + "retainedNonce": "n-1", + "parameterizedCases": [ + {"id": "LEASE-ORGANIZATION", "claim": "organizationRef", "mutation": "org-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-JOB", "claim": "jobRef", "mutation": "job-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-OPERATION", "claim": "operation", "mutation": "publish", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-SOURCE", "claim": "sourceRef", "mutation": "source-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-RESOURCE", "claim": "resourceRef", "mutation": "resource-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-REVISION", "claim": "revisionRef", "mutation": "revision-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-SERVICE-ACTOR", "claim": "serviceActorRef", "mutation": "worker-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-WORKLOAD", "claim": "workload", "mutation": "workload-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-POLICY-EPOCH", "claim": "policyEpoch", "mutation": 3, "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-AUDIENCE", "claim": "audienceDigest", "mutation": "audience-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-IDEMPOTENCY", "claim": "idempotencyKey", "mutation": "idem-b", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-GENERATION", "claim": "generation", "mutation": 1, "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-ISSUED-AT", "claim": "issuedAt", "mutation": "outside-bound-window", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-EXPIRY", "claim": "expiresAt", "mutation": "expired", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-NONCE", "claim": "nonce", "mutation": "n-2", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-REPLAY", "claim": "redemption", "mutation": "reuse-consumed-lease", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0}, + {"id": "LEASE-USER-IMPERSONATION", "claim": "actorKind", "mutation": "UserActor", "expectedStatus": 404, "expectedOutcome": "work_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0} + ] + }, + "operation": { + "interface": "Supply worker lease redemption", + "request": "Redeem mutated consumed lease", + "durableComparison": "Compare mutation ledger before and after the replay." + }, + "expected": { + "externalResponse": {"status": 404, "code": "work_not_available", "leaseClaimsEchoed": false}, + "packageOrError": {"kind": "worker_rejection", "reasonVisibleToWorker": "generic_unavailable", "newReceiptCreated": false}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 0}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 1}, + "io": {"providerCalls": 0, "indexCalls": 0, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["TENANT-OWNERSHIP-001", "WORKER-LEASE-007", "TRACE-REDACTION-012"], + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#implementation-decisions", + "docs/security/安全负向测试清单.md#4-workeroutbox-与-publication", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-009", + "title": "Unavailable source-native ACL fails closed at M0", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "future", + "m0Expectation": "fail_closed", + "upgradeTrigger": "The owning File authorized-Package issue #23 first upgrades this fixture for Mirrored FileSourceAccess; a later federated-provider issue upgrades it for real Live source-native ACL evidence." + }, + "setup": { + "preconditions": [ + "source-future declares Live source-native ACL as required for resource-future.", + "source-coarse genuinely lacks finer ACL semantics, but no complete active Weak SourcePolicy or sensitivity decision exists at M0.", + "file-source-a has no complete active versioned PostgreSQL FileSourceAccess grant; host ownership and mode are never engine authority.", + "No production Provider carrier or source sandbox for that capability exists at M0.", + "A service credential could read the source object but is not end-user delivery authority." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "purpose": "context.answer", + "requiredAclMode": "Live" + } + }, + "adversarialMutation": { + "kind": "service_account_acl_substitution", + "caseRef": "PROV-010", + "requestedFallback": "Weak", + "missingCapability": "live-source-native-acl", + "parameterizedCases": [ + {"id": "PROV-013", "mutation": "A declared Live or strong ACL check times out, returns 429, or fails with 5xx.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Fail closed or return a typed unavailable gap with zero Evidence; never substitute Weak proof."}, + {"id": "PROV-014", "mutation": "Mirrored ACL exceeds its freshness SLA or omits aclAsOf or sourceVersion.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Reject, resynchronize, or use an equally strong supported Live check; every allowed Mirrored Evidence exposes proofKind Mirrored, aclAsOf, sourceVersion, and its declared freshness bound."}, + {"id": "PROV-015", "mutation": "A coarse-membership source requests Weak proof without a complete active Weak SourcePolicy and sensitivity decision.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Weak is allowed only when the source genuinely lacks finer ACL semantics and explicitly declares it; incomplete, stale, unknown-sensitivity, or sensitive cases deny, while an allowed Package exposes proofKind Weak and declared as-of/freshness."}, + {"id": "PROV-018", "mutation": "FileSourceAccess is missing, incomplete, unknown, cross-Organization, cross-Resource, or not the active manifest version.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Deny generically with zero Evidence and no implicit owner/public fallback; an allowed File result uses the active versioned PostgreSQL grant and exposes complete Mirrored proof and freshness fields."}, + {"id": "PROV-019", "mutation": "Host operating-system owner, mode, or ACL is permissive while FileSourceAccess is absent or denied.", "expectedStatus": 200, "expectedOutcome": "request_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Host metadata never changes engine authorization; only active versioned PostgreSQL FileSourceAccess may yield an allowed Mirrored proof."} + ] + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "Execute PROV-010 and each named ACL-proof or FileSourceAccess mutation independently; at M0 attempt Acquire only through the unavailable capability gate", + "phase": "M0 capability availability check before Provider invocation; owning carrier later upgrades each activatedOracle at its highest public seam" + }, + "expected": { + "externalResponse": {"status": 200, "body": {"kind": "request_not_available", "retryable": false}}, + "packageOrError": {"kind": "request_not_available", "aclModeUsed": null, "weakFallbackCount": 0, "capabilityReportedAsPass": false}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 0}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 0, "indexCalls": 0, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["INDEX-NOT-AUTHORITY-005", "REVOCATION-006"], + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#33-contextprovider-and-source-projection", + "docs/security/Test-Architecture-与可验证性设计.md#13-v1-acceptance-scenarios", + "docs/security/安全负向测试清单.md#5-providerfederation-与-source-native-acl", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-010", + "title": "Unavailable citation carrier denies revoked citation open at M0", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "future", + "m0Expectation": "fail_closed", + "upgradeTrigger": "The owning M2 OpenCitation implementation issue upgrades this fixture only after current-opener authorization and distinct token variants exist at the public HTTP seam." + }, + "setup": { + "preconditions": [ + "citation-ref-1 is a locator recorded in an earlier Package for member-a.", + "member-a's Resource grant has since been revoked.", + "M0 has no active OpenCitation carrier and cannot redeem citation or continuation wire variants." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "currentPolicyEpoch": 6, + "purpose": "citation.open" + } + }, + "adversarialMutation": { + "kind": "revoked_citation_locator_open", + "locator": "citation-ref-1", + "originalPolicyEpoch": 5, + "attemptedAlternateUse": "ContinuationToken" + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "OpenCitation citation-ref-1", + "phase": "inactive-capability gate before source or blob I/O" + }, + "expected": { + "externalResponse": {"status": 200, "body": {"kind": "citation_not_available"}}, + "packageOrError": {"kind": "citation_not_available", "citationFieldsReturned": 0, "capabilityStatus": "unavailable", "capabilityReportedAsPass": false}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 0}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 0, "indexCalls": 0, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["REVOCATION-006", "CITATION-AUTH-010", "NON-ENUMERATION-009"], + "authorityRefs": [ + "docs/design/2026-07-18-context-engine-implementation-design.md#53-tokens-and-locators", + "docs/security/安全负向测试清单.md#6-runtimeassemblycitation-与-egress", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-011", + "title": "Denied and not-found probes are externally indistinguishable", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issue #14 upgrades this authority fixture with canonical denied/not-found HTTP behavior and the preregistered response comparison." + }, + "setup": { + "preconditions": [ + "member-a is authenticated in org-a; resource-cross-org belongs to org-b and resource-same-org-denied belongs to org-a without a grant for member-a.", + "resource-missing does not exist in org-a.", + "The experiment freezes canonical status, body, relevant headers, domain outcome, and the in-fixture per-run normalization allowlist before all three probes; timing equality is deferred to the preregistered M5 gate." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "purpose": "context.answer" + } + }, + "adversarialMutation": { + "kind": "existence_oracle_triplet", + "probes": ["resource-cross-org", "resource-same-org-denied", "resource-missing"], + "order": ["cross_organization_denied", "same_organization_denied", "missing"] + }, + "operation": { + "interface": "ContextRuntime.resolve", + "request": "Acquire each of the three probes independently", + "comparisonFields": ["status", "body", "headers", "domainOutcome"], + "normalizationAllowlist": ["body.package.packageId", "body.package.packageDigest", "body.package.decisionRef", "body.package.asOf", "body.package.expiresAt", "body.package.budgetUsage.elapsedMs", "body.egressGrant", "headers.X-Context-Request-Id"] + }, + "expected": { + "externalResponse": { + "status": 200, + "body": { + "kind": "resolved", + "package": { + "packageId": "normalized-package-ref", + "packageDigest": "normalized-package-digest", + "purpose": "context.answer", + "audienceDigest": "audience-a", + "policyEpoch": "current-policy-epoch", + "decisionRef": "normalized-decision-ref", + "releaseManifestRef": "active-release-manifest-ref", + "retentionPolicyRef": "active-retention-policy-ref", + "asOf": "normalized-as-of", + "expiresAt": "normalized-expiry", + "tokenizerRef": "active-tokenizer-ref", + "blocks": [], + "evidence": [], + "gaps": [], + "coverage": {"status": "empty", "reason": "no_authorized_evidence"}, + "budgetUsage": {"tokens": 0, "providerCalls": 0, "costMicrounits": 0, "elapsedMs": 0} + }, + "egressGrant": "normalized-egress-grant" + }, + "headers": {"Content-Type": "application/json", "Cache-Control": "no-store", "X-Context-Request-Id": "normalized-request-id"}, + "normalizedByteIdenticalAcrossProbes": true, + "timingEqualityClaimed": false + }, + "packageOrError": {"kind": "ContextPackage", "packageCount": 3, "coverageStatus": "empty", "coverageReason": "no_authorized_evidence", "deniedCountExposed": false, "existenceDetailCount": 0}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 0}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 0, "indexCalls": 0, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["NON-ENUMERATION-009", "TRACE-REDACTION-012"], + "authorityRefs": [ + "docs/security/context-engine-threat-model.md#6-threat-register", + "docs/security/安全负向测试清单.md#7-tracedebugnon-enumeration-与-learning", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + }, + { + "id": "ACCEPT-012", + "title": "Context and Action capabilities remain separated at M0", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "unavailable", + "m0Expectation": "fail_closed", + "upgradeTrigger": "Issue #18 installs the distinct inactive ContextAccessTicket and ActionTicket audiences; the owning M2 ActionPlane issue upgrades this fixture to prepare and perform against a real Sender." + }, + "setup": { + "preconditions": [ + "A syntactically valid ContextAccessTicket is bound to org-a, member-a, context.read, and source-a.", + "conversation-b contains member-a and member-b; both may read public evidence, only member-a may read private-field-a, and the platform can expose history to future members.", + "The trusted group AudienceSnapshot is complete for member-a and member-b at resolve time; membership drift, lookup failure, and future-history exposure are independently mutated below.", + "Public and asker-private delivery require independent audience-bound resolves; create-placeholder and finalize-reply require distinct prepare/perform tickets and idempotency keys.", + "No M0 ActionPlane or Sender carrier is active.", + "No ActionTicket has been prepared for destination conversation-b or payload digest digest-b." + ], + "trustedIdentity": { + "organizationRef": "org-a", + "principalRef": "member-a", + "capabilityAudience": "context.read", + "purpose": "context.answer" + } + }, + "adversarialMutation": { + "kind": "parameterized_audience_and_action_separation", + "caseRef": "ACTION-001", + "ticketKind": "ContextAccessTicket", + "requestedEffect": "send_message", + "destination": "conversation-b", + "targetOrganization": "org-b", + "parameterizedCases": [ + {"id": "AUTH-010", "mutation": "A trusted group AudienceSnapshot contains an unknown, unbound, external, or lookup-failed member.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "The public Package, group-send bytes, and group effects are zero; only a separately resolved explicit private flow may proceed."}, + {"id": "RUN-014", "mutation": "One question needs a group-public result and an asker-private supplement.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Resolve two independent audience-bound Packages and EgressGrants; never derive or split the public Package from the asker-private Package, and cross-audience private bytes remain zero."}, + {"id": "EGR-003", "mutation": "BotDelivery bypasses the Kernel, expands AudienceSnapshot, or slices asker-private content for public delivery.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Reject at the trusted boundary with zero public, model, or sender bytes and zero public effects; restricted audit records only the violation category."}, + {"id": "EGR-005", "mutation": "A member joins or leaves after resolve, or send-time audience lookup is stale, unknown, or unavailable.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "ActionPlane.prepare returns AudienceChanged for stale or unknown audience; no ActionTicket, group bytes, or effect is produced, and delivery must re-resolve or use a separate private flow."}, + {"id": "EGR-006", "mutation": "Future members can read group history while the old-snapshot Package contains content not authorized for every future reader.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Protected public and future-member bytes and public effects remain zero unless verifiable delete/redaction compensation proves safety; otherwise use a generic notice, per-opener citation, or separate private delivery."}, + {"id": "ACTION-001", "mutation": "Use a ContextAccessTicket for refund, update, edit, or send.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "ActionPlane rejects the wrong capability or audience with effect zero; read authority never creates a write effect."}, + {"id": "ACTION-002", "mutation": "Use an ActionTicket for ContextRuntime resolve or Provider read.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "The read boundary rejects the wrong capability with zero Evidence and content work, and does not consume the ActionTicket."}, + {"id": "ACTION-003", "mutation": "Use a CreatePlaceholder ticket for FinalizeReply or another effect, or reverse the ticket/payload pairing.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "ActionPlane.perform returns Rejected with effect zero for every operation, destination, audience, or payload-class mismatch."}, + {"id": "ACTION-004", "mutation": "Replay separately prepared create and finalize tickets sequentially and concurrently.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Each distinct ticket produces at most one corresponding Applied effect; every successful replay returns AlreadyApplied with the same stored receipt and adds zero effects."}, + {"id": "ACTION-005", "mutation": "Bypass prepare or present a caller-signed ActionTicket directly to perform.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "ActionPlane.perform returns Rejected with effect zero, creates no ticket, and makes no Sender call."}, + {"id": "ACTION-006", "mutation": "Mutate Organization, operation, destination, audience, payload digest, epoch, expiry, approval tier, idempotency key, or nonce after prepare.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Exact binding validation returns Rejected with zero new, wrong-Organization, or wrong-audience effects."}, + {"id": "ACTION-007", "mutation": "After a timeout-after-send or ambiguous provider result, retry with a new ticket, key, or attempt id.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Return ReconciliationRequired with the original providerAttemptRef; create no replacement ticket or additional effect and reconcile only under the original id."}, + {"id": "ACTION-008", "mutation": "Prepare encounters policy denial, audience drift, or temporary unavailability.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Return exactly GenericDenied, AudienceChanged, or RetryableUnavailable for the matching condition, with no ActionTicket, Sender call, or effect."}, + {"id": "ACTION-009", "mutation": "Perform receives a wrong ticket or payload, or an external attempt remains ambiguous.", "expectedStatus": 404, "expectedOutcome": "action_not_available", "expectedNewDurableEffects": 0, "expectedWrongOrganizationEffects": 0, "expectedContentWorkCalls": 0, "activatedOracle": "Return Rejected(effect zero) for deterministic mismatch or ReconciliationRequired with the original providerAttemptRef for ambiguity; never mint retry authority or an extra effect."} + ] + }, + "operation": { + "interface": "ContextRuntime, BotDelivery, ActionPlane, and Sender conformance seams", + "request": "Execute each named audience or Action mutation independently; at M0 every case first crosses the unavailable Action capability gate", + "phase": "M0 inactive-capability gate before content or Sender I/O; owning M2 carriers later upgrade each activatedOracle at its highest public seam" + }, + "expected": { + "externalResponse": {"status": 404, "code": "action_not_available", "body": {"kind": "generic_unavailable"}}, + "packageOrError": {"kind": "action_rejection", "actionTicketCreated": false, "contextTicketConsumed": false, "capabilityReportedAsPass": false}, + "evidence": {"unauthorizedEvidenceCount": 0, "unauthorizedContentBytes": 0, "missingContextFallbackCount": 0, "outboundBytes": 0}, + "businessEffects": {"wrongOrganizationEffectCount": 0, "mutationEffectCount": 0, "totalEffectsAfterScenario": 0}, + "io": {"providerCalls": 0, "indexCalls": 0, "modelCalls": 0, "actionCalls": 0} + }, + "invariantRefs": ["SCOPE-INTERSECTION-004", "TRANSPORT-UNTRUSTED-008", "EGRESS-011", "ACTION-SEPARATION-014"], + "authorityRefs": [ + "docs/agents/prd-contextengine-implementation.md#implementation-decisions", + "docs/security/Test-Architecture-与可验证性设计.md#75-egress-与-action-contract", + "docs/security/Test-Architecture-与可验证性设计.md#13-v1-acceptance-scenarios", + "docs/security/安全负向测试清单.md#6-runtimeassemblycitation-与-egress", + "docs/decisions/0019-security-catalog-normalization.md#decision" + ] + } + ] +} diff --git a/scripts/validate_security_catalog.py b/scripts/validate_security_catalog.py new file mode 100644 index 00000000..d64214f9 --- /dev/null +++ b/scripts/validate_security_catalog.py @@ -0,0 +1,1909 @@ +#!/usr/bin/env python3 +"""Validate and report the versioned ContextEngine security catalog. + +The ``.yaml`` catalog deliberately uses JSON-compatible YAML so this D0 check +has no dependency on an application environment or a third-party YAML parser. +""" + +from __future__ import annotations + +import argparse +import hashlib +import html +import json +import re +import subprocess +import sys +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import unquote + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CATALOG_PATH = REPOSITORY_ROOT / "eval/catalogs/security-invariants.yaml" +DEFAULT_SCHEMA_PATH = REPOSITORY_ROOT / "eval/catalogs/security-catalog.schema.json" +SUPPORTED_CATALOG_VERSION = "1.0.0" +EXPECTED_INVARIANT_COUNT = 15 +EXPECTED_FIXTURE_COUNT = 12 +ID_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-[0-9]{3}$") + +CANONICAL_INVARIANT_IDS: tuple[str, ...] = ( + "TENANT-OWNERSHIP-001", + "TENANT-FK-002", + "RLS-FAIL-CLOSED-003", + "SCOPE-INTERSECTION-004", + "INDEX-NOT-AUTHORITY-005", + "REVOCATION-006", + "WORKER-LEASE-007", + "TRANSPORT-UNTRUSTED-008", + "NON-ENUMERATION-009", + "CITATION-AUTH-010", + "EGRESS-011", + "TRACE-REDACTION-012", + "ACTION-SEPARATION-014", + "CROSS-ORG-LEARN-015", + "RELEASE-OWNER-019", +) +CANONICAL_FIXTURE_IDS: tuple[str, ...] = tuple( + f"ACCEPT-{number:03d}" for number in range(1, EXPECTED_FIXTURE_COUNT + 1) +) + +HARD_ORACLES: tuple[str, ...] = ( + "Unauthorized Evidence", + "wrong-Organization effect", + "missing-context fallback", +) + +TOP_LEVEL_FIELDS = ( + "catalogVersion", + "authority", + "hardOracles", + "invariants", + "fixtures", +) +INVARIANT_FIELDS = ( + "id", + "title", + "purpose", + "threatRefs", + "protectedAssets", + "deterministicOracle", + "hardOracleRefs", + "applicability", + "capabilityRef", + "requiredMilestones", + "evidenceStatus", + "expectedEvidence", + "authorityRefs", +) +EXPECTED_EVIDENCE_FIELDS = ("property", "postgres", "runtimeOrDelivery") +FIXTURE_FIELDS = ( + "id", + "title", + "decisionStatus", + "carrier", + "setup", + "adversarialMutation", + "operation", + "expected", + "invariantRefs", + "authorityRefs", +) +CARRIER_FIELDS = ("statusAtM0", "m0Expectation", "upgradeTrigger") +SETUP_FIELDS = ("preconditions", "trustedIdentity") +EXPECTED_FIELDS = ( + "externalResponse", + "packageOrError", + "evidence", + "businessEffects", + "io", +) +EVIDENCE_FIELDS = ( + "unauthorizedEvidenceCount", + "unauthorizedContentBytes", + "missingContextFallbackCount", + "outboundBytes", +) +BUSINESS_EFFECT_FIELDS = ( + "wrongOrganizationEffectCount", + "mutationEffectCount", + "totalEffectsAfterScenario", +) +IO_FIELDS = ("providerCalls", "indexCalls", "modelCalls", "actionCalls") +PARAMETERIZED_CASE_FIELDS = ( + "id", + "mutation", + "expectedStatus", + "expectedOutcome", + "expectedNewDurableEffects", + "expectedWrongOrganizationEffects", + "expectedContentWorkCalls", +) + +CANONICAL_REQUIRED_MILESTONES: dict[str, tuple[str, ...]] = { + "TENANT-OWNERSHIP-001": ("M0", "M1"), + "TENANT-FK-002": ("M0", "M1"), + "RLS-FAIL-CLOSED-003": ("M0",), + "SCOPE-INTERSECTION-004": ("M0", "M1", "M5"), + "INDEX-NOT-AUTHORITY-005": ("M0", "M1", "M3"), + "REVOCATION-006": ("M1", "M2"), + "WORKER-LEASE-007": ("M1", "M3"), + "TRANSPORT-UNTRUSTED-008": ("M1", "M2"), + "NON-ENUMERATION-009": ("M1", "M5"), + "CITATION-AUTH-010": ("M2", "M3"), + "EGRESS-011": ("M2", "M5"), + "TRACE-REDACTION-012": ("M0", "M1"), + "ACTION-SEPARATION-014": ("M2",), + "CROSS-ORG-LEARN-015": ("M0", "M3"), + "RELEASE-OWNER-019": ("M0", "M3"), +} + +RUNTIME_OUTCOME_KINDS: dict[str, tuple[str, str]] = { + "ACCEPT-001": ("resolved", "ContextPackage"), + "ACCEPT-005": ("request_not_available", "request_not_available"), + "ACCEPT-009": ("request_not_available", "request_not_available"), + "ACCEPT-010": ("citation_not_available", "citation_not_available"), + "ACCEPT-011": ("resolved", "ContextPackage"), +} + +CANONICAL_FAIL_CLOSED_OUTCOMES: dict[str, dict[str, object]] = { + "ACCEPT-007": { + "externalResponse": { + "status": 422, + "code": "invalid_request", + "fieldNamesEchoed": False, + }, + "packageOrError": { + "kind": "error", + "contextPackageCreated": False, + "trustedContextConstructedFromBody": False, + }, + }, + "ACCEPT-008": { + "externalResponse": { + "status": 404, + "code": "work_not_available", + "leaseClaimsEchoed": False, + }, + "packageOrError": { + "kind": "worker_rejection", + "reasonVisibleToWorker": "generic_unavailable", + "newReceiptCreated": False, + }, + }, + "ACCEPT-012": { + "externalResponse": { + "status": 404, + "code": "action_not_available", + "body": {"kind": "generic_unavailable"}, + }, + "packageOrError": { + "kind": "action_rejection", + "actionTicketCreated": False, + "contextTicketConsumed": False, + "capabilityReportedAsPass": False, + }, + }, +} + +NON_RETRYABLE_RUNTIME_FIXTURES = frozenset({"ACCEPT-005", "ACCEPT-009"}) +RESOLVED_EMPTY_RUNTIME_FIXTURES = frozenset({"ACCEPT-001", "ACCEPT-011"}) + +TRANSPORT_CASE_IDS: tuple[str, ...] = ( + "BODY-INJECTION", + "DELIV-001", + "DELIV-002", + "DELIV-003", + "DELIV-004", +) +WORKER_LEASE_CASE_IDS: tuple[str, ...] = ( + "LEASE-ORGANIZATION", + "LEASE-JOB", + "LEASE-OPERATION", + "LEASE-SOURCE", + "LEASE-RESOURCE", + "LEASE-REVISION", + "LEASE-SERVICE-ACTOR", + "LEASE-WORKLOAD", + "LEASE-POLICY-EPOCH", + "LEASE-AUDIENCE", + "LEASE-IDEMPOTENCY", + "LEASE-GENERATION", + "LEASE-ISSUED-AT", + "LEASE-EXPIRY", + "LEASE-NONCE", + "LEASE-REPLAY", + "LEASE-USER-IMPERSONATION", +) +ACL_PROOF_CASE_IDS: tuple[str, ...] = ( + "PROV-013", + "PROV-014", + "PROV-015", + "PROV-018", + "PROV-019", +) +AUDIENCE_ACTION_CASE_IDS: tuple[str, ...] = ( + "AUTH-010", + "RUN-014", + "EGR-003", + "EGR-005", + "EGR-006", + "ACTION-001", + "ACTION-002", + "ACTION-003", + "ACTION-004", + "ACTION-005", + "ACTION-006", + "ACTION-007", + "ACTION-008", + "ACTION-009", +) + +TRANSPORT_CASE_OUTCOMES: dict[str, tuple[int, str]] = { + "BODY-INJECTION": (422, "invalid_request"), + "DELIV-001": (200, "request_not_available"), + "DELIV-002": (200, "request_not_available"), + "DELIV-003": (200, "request_not_available"), + "DELIV-004": (200, "request_not_available"), +} +WORKER_LEASE_CASE_OUTCOME = (404, "work_not_available") +ACL_PROOF_CASE_OUTCOME = (200, "request_not_available") +AUDIENCE_ACTION_CASE_OUTCOME = (404, "action_not_available") + +CANONICAL_ACTIVATED_ORACLE_DIGESTS: dict[str, str] = { + "PROV-013": "597d1b8511d430398f0de6982df350a6158ef57135b33eb1636b19d57222e7f6", + "PROV-014": "105e5e581bd363375e86c098f3bcded295024d91f380e7d3221545c5b5877bdd", + "PROV-015": "b34bfee66ab61a1a187182d8137412151c0f52b1c60ba030febecf0af9b2aa24", + "PROV-018": "f39c044b3475f536c990eae3c78c422b08080c134ec03c9eeffbd1aa31de01f4", + "PROV-019": "933086bc50969a6c04dfed9b46d3509e25e815bf58d5c025dfde298578d05698", + "AUTH-010": "ee7342e4fa1b5a5f43337b43e3dfd0a0b1a6065cdefcaf610eabc48397767dfb", + "RUN-014": "5edc224d7cf4d7b44773f8c0b5a8d6065a83113fb0361dd799239128c22e4393", + "EGR-003": "a7e1b84b711534fe9a8bf2f399d117f508f357aea498f07e562e6bdd027e4822", + "EGR-005": "dfce84151968132f0df53f9261981c8e1bb9d3bbffe9920d95cc106649ceda25", + "EGR-006": "50196c9621df31c1c7e4b86613da509d64d5a81c3aae55d3bd51e88582d187f6", + "ACTION-001": "bfc12ae00de249b37531f6521a49979e2d43dab1e3e229426e1f3a5e7cfb3ee9", + "ACTION-002": "68d07a06d265d0f3e833db78521eac5b1e2e0f90229f800e6291e2210710338e", + "ACTION-003": "9f18bb396da1b6ff513366704c8b0d4ed913430cb54a153e9d6a56a92bf3061e", + "ACTION-004": "f9140ac2129845d1bb582f9fa84eed77ace2ce1d840ae0abddfa209d4d4a7fae", + "ACTION-005": "a285bc30ba4b55755c17e565d6a40d0fdb900f952b5b91fe9efbdad36ffbfb09", + "ACTION-006": "f1701a889c9b0613e75eda58cf4fb129ab5135d39cc352b079c6932f943df4d9", + "ACTION-007": "3e1acc8a53d43f00f5214ab5c339b685dc039cb5a2a4d38f128c9ff94888cc57", + "ACTION-008": "a62b4872153da75ad63b2f9678efa4c38250e2464c82f3ee15d55f33545f007a", + "ACTION-009": "9ea27dea249b98198811ff1bfffa1e5307d59f84bea8e31050c5e2ea0b3528ce", +} + +REQUIRED_RUNTIME_EVIDENCE: dict[str, tuple[str, ...]] = { + "SCOPE-INTERSECTION-004": ("AUTH-010", "RUN-014"), + "INDEX-NOT-AUTHORITY-005": ( + "PROV-010", + "PROV-013", + "PROV-014", + "PROV-015", + "PROV-018", + "PROV-019", + "PROV-020", + ), + "REVOCATION-006": ( + "PROV-013", + "PROV-014", + "PROV-015", + "PROV-018", + "PROV-019", + "PROV-020", + ), + "TRANSPORT-UNTRUSTED-008": ( + "DELIV-001", + "DELIV-002", + "DELIV-003", + "DELIV-004", + ), + "EGRESS-011": ("EGR-003", "EGR-005", "EGR-006", "RUN-014"), + "ACTION-SEPARATION-014": tuple(f"ACTION-{number:03d}" for number in range(1, 10)), +} + + +@dataclass(frozen=True) +class ValidationReport: + """The stable information emitted by the catalog validation CLI.""" + + invariant_count: int + fixture_count: int + fixture_mappings: tuple[tuple[str, tuple[str, ...]], ...] + + +class CatalogValidationError(ValueError): + """One or more independently actionable catalog validation failures.""" + + def __init__(self, errors: Sequence[str]): + self.errors = tuple(errors) + super().__init__("\n".join(self.errors)) + + +class _Collector: + def __init__(self) -> None: + self.errors: list[str] = [] + + def add(self, path: str, message: str) -> None: + error = f"{path}: {message}" + if error not in self.errors: + self.errors.append(error) + + def require_mapping(self, value: object, path: str) -> Mapping[str, Any] | None: + if not isinstance(value, Mapping): + self.add(path, "must be an object") + return None + return value + + def require_fields( + self, value: Mapping[str, Any], fields: Sequence[str], path: str + ) -> None: + for field in fields: + if field not in value: + self.add(f"{path}.{field}", "is required") + + def require_exact_fields( + self, value: Mapping[str, Any], fields: Sequence[str], path: str + ) -> None: + self.require_fields(value, fields, path) + allowed = set(fields) + for field in value: + if field not in allowed: + self.add(f"{path}.{field}", "is not allowed") + + def require_nonempty_string(self, value: object, path: str) -> bool: + if not isinstance(value, str) or not value.strip(): + self.add(path, "must be a non-empty string") + return False + return True + + def require_string_list(self, value: object, path: str) -> list[str] | None: + if not isinstance(value, list) or not value: + self.add(path, "must be a non-empty array of non-empty strings") + return None + valid = True + for index, entry in enumerate(value): + valid = self.require_nonempty_string(entry, f"{path}[{index}]") and valid + string_entries = [entry for entry in value if isinstance(entry, str)] + if len(string_entries) != len(set(string_entries)): + self.add(path, "must contain unique strings") + valid = False + return value if valid else None + + def require_nonempty_object( + self, value: object, path: str + ) -> Mapping[str, Any] | None: + mapping = self.require_mapping(value, path) + if mapping is not None and not mapping: + self.add(path, "must not be empty") + return None + return mapping + + def require_count(self, value: object, path: str) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + self.add(path, "must be an integer greater than or equal to 0") + return None + return value + + +def load_document(path: str | Path) -> dict[str, Any]: + """Load a JSON document (and therefore the catalog's JSON-compatible YAML).""" + + document_path = Path(path) + try: + with document_path.open(encoding="utf-8") as stream: + value = json.load(stream) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise CatalogValidationError( + [f"{document_path}: cannot load JSON-compatible YAML/JSON: {error}"] + ) from error + if not isinstance(value, dict): + raise CatalogValidationError( + [f"{document_path}: document root must be an object"] + ) + return value + + +def _validate_authority(catalog: Mapping[str, Any], collector: _Collector) -> set[str]: + authority = collector.require_mapping(catalog.get("authority"), "authority") + if authority is None: + return set() + collector.require_exact_fields( + authority, ("issueRefs", "documentRefs", "reconciliation"), "authority" + ) + issue_refs = collector.require_string_list( + authority.get("issueRefs"), "authority.issueRefs" + ) + document_refs = collector.require_string_list( + authority.get("documentRefs"), "authority.documentRefs" + ) + collector.require_nonempty_string( + authority.get("reconciliation"), "authority.reconciliation" + ) + return set(issue_refs or ()) | set(document_refs or ()) + + +def _validate_hard_oracles(catalog: Mapping[str, Any], collector: _Collector) -> None: + hard_oracles = catalog.get("hardOracles") + if not isinstance(hard_oracles, list): + collector.add("hardOracles", "must be an array") + return + if len(hard_oracles) != len(HARD_ORACLES): + collector.add("hardOracles", "must contain exactly 3 hard oracles") + + found_names: list[str] = [] + for index, value in enumerate(hard_oracles): + path = f"hardOracles[{index}]" + oracle = collector.require_mapping(value, path) + if oracle is None: + continue + collector.require_exact_fields(oracle, ("name", "requiredValue", "veto"), path) + name = oracle.get("name") + if collector.require_nonempty_string(name, f"{path}.name"): + found_names.append(name) + required_value = oracle.get("requiredValue") + if isinstance(required_value, bool) or required_value != 0: + collector.add(f"{path}.requiredValue", "must be the numeric constant 0") + if oracle.get("veto") is not True: + collector.add(f"{path}.veto", "must be true") + + if len(found_names) != len(set(found_names)): + collector.add("hardOracles", "names must be unique") + if set(found_names) != set(HARD_ORACLES): + collector.add( + "hardOracles", + "names must be exactly: " + ", ".join(HARD_ORACLES), + ) + elif tuple(found_names) != HARD_ORACLES: + collector.add( + "hardOracles", + "must use the canonical order: " + ", ".join(HARD_ORACLES), + ) + + +def _validate_identifier(value: object, path: str, collector: _Collector) -> str | None: + if not collector.require_nonempty_string(value, path): + return None + assert isinstance(value, str) + if ID_PATTERN.fullmatch(value) is None: + collector.add(path, f"must match {ID_PATTERN.pattern}") + return None + return value + + +def _validate_authority_refs( + value: object, path: str, known_authority_refs: set[str], collector: _Collector +) -> None: + refs = collector.require_string_list(value, path) + if refs is None: + return + for index, ref in enumerate(refs): + base_ref = ref.split("#", 1)[0] + if ref not in known_authority_refs and base_ref not in known_authority_refs: + collector.add(f"{path}[{index}]", f"unknown authority reference {ref!r}") + + +def _validate_applicability(value: object, path: str, collector: _Collector) -> None: + applicability = collector.require_mapping(value, path) + if applicability is None: + return + collector.require_exact_fields( + applicability, ("mode", "applicableFrom", "rationale"), path + ) + mode = applicability.get("mode") + if mode not in {"required", "conditional", "not_applicable"}: + collector.add( + f"{path}.mode", "must be required, conditional, or not_applicable" + ) + return + if mode in {"required", "conditional"}: + collector.require_nonempty_string( + applicability.get("applicableFrom"), f"{path}.applicableFrom" + ) + if applicability.get("rationale") is not None: + collector.require_nonempty_string( + applicability.get("rationale"), f"{path}.rationale" + ) + else: + if applicability.get("applicableFrom") is not None: + collector.add( + f"{path}.applicableFrom", "must be null when mode is not_applicable" + ) + collector.require_nonempty_string( + applicability.get("rationale"), f"{path}.rationale" + ) + + +def _validate_invariants( + catalog: Mapping[str, Any], known_authority_refs: set[str], collector: _Collector +) -> set[str]: + invariants = catalog.get("invariants") + if not isinstance(invariants, list): + collector.add("invariants", "must be an array") + return set() + if len(invariants) != EXPECTED_INVARIANT_COUNT: + collector.add("invariants", "must contain exactly 15 entries") + + invariant_ids: list[str] = [] + for index, value in enumerate(invariants): + path = f"invariants[{index}]" + invariant = collector.require_mapping(value, path) + if invariant is None: + continue + collector.require_exact_fields(invariant, INVARIANT_FIELDS, path) + invariant_id = _validate_identifier( + invariant.get("id"), f"{path}.id", collector + ) + if invariant_id is not None: + invariant_ids.append(invariant_id) + for field in ("title", "purpose", "deterministicOracle", "capabilityRef"): + collector.require_nonempty_string(invariant.get(field), f"{path}.{field}") + for field in ("threatRefs", "protectedAssets"): + collector.require_string_list(invariant.get(field), f"{path}.{field}") + + hard_oracle_refs = collector.require_string_list( + invariant.get("hardOracleRefs"), f"{path}.hardOracleRefs" + ) + if hard_oracle_refs is not None: + for ref_index, ref in enumerate(hard_oracle_refs): + if ref not in HARD_ORACLES: + collector.add( + f"{path}.hardOracleRefs[{ref_index}]", + f"unknown hard oracle {ref!r}", + ) + applicability = collector.require_mapping( + invariant.get("applicability"), f"{path}.applicability" + ) + _validate_applicability( + invariant.get("applicability"), f"{path}.applicability", collector + ) + required_milestones = collector.require_string_list( + invariant.get("requiredMilestones"), f"{path}.requiredMilestones" + ) + if invariant_id in CANONICAL_REQUIRED_MILESTONES: + canonical_milestones = CANONICAL_REQUIRED_MILESTONES[invariant_id] + first_required = canonical_milestones[0] + if ( + applicability is not None + and applicability.get("applicableFrom") != first_required + ): + collector.add( + f"{path}.applicability.applicableFrom", + f"must be {first_required!r} for {invariant_id}", + ) + if ( + required_milestones is not None + and tuple(required_milestones) != canonical_milestones + ): + collector.add( + f"{path}.requiredMilestones", + "must be the canonical sequence " + f"{list(canonical_milestones)!r} for {invariant_id}", + ) + if invariant.get("evidenceStatus") != "accepted": + collector.add(f"{path}.evidenceStatus", "must be accepted") + + expected_evidence = collector.require_mapping( + invariant.get("expectedEvidence"), f"{path}.expectedEvidence" + ) + if expected_evidence is not None: + collector.require_exact_fields( + expected_evidence, EXPECTED_EVIDENCE_FIELDS, f"{path}.expectedEvidence" + ) + for field in EXPECTED_EVIDENCE_FIELDS: + collector.require_string_list( + expected_evidence.get(field), f"{path}.expectedEvidence.{field}" + ) + runtime_evidence = collector.require_string_list( + expected_evidence.get("runtimeOrDelivery"), + f"{path}.expectedEvidence.runtimeOrDelivery", + ) + if ( + runtime_evidence is not None + and invariant_id in REQUIRED_RUNTIME_EVIDENCE + ): + for case_id in REQUIRED_RUNTIME_EVIDENCE[invariant_id]: + if case_id not in runtime_evidence: + collector.add( + f"{path}.expectedEvidence.runtimeOrDelivery", + "must preserve absorbed derived case " + f"{case_id!r} for {invariant_id}", + ) + _validate_authority_refs( + invariant.get("authorityRefs"), + f"{path}.authorityRefs", + known_authority_refs, + collector, + ) + + seen: set[str] = set() + for invariant_id in invariant_ids: + if invariant_id in seen: + collector.add("invariants", f"duplicate id {invariant_id!r}") + seen.add(invariant_id) + if tuple(invariant_ids) != CANONICAL_INVARIANT_IDS: + collector.add( + "invariants", + "ids must be the canonical ordered set: " + + ", ".join(CANONICAL_INVARIANT_IDS), + ) + return seen + + +def _validate_metric_object( + value: object, fields: Sequence[str], path: str, collector: _Collector +) -> dict[str, int]: + result: dict[str, int] = {} + metrics = collector.require_mapping(value, path) + if metrics is None: + return result + collector.require_exact_fields(metrics, fields, path) + for field in fields: + count = collector.require_count(metrics.get(field), f"{path}.{field}") + if count is not None: + result[field] = count + return result + + +def _validate_parameterized_case_ids( + mutation: Mapping[str, Any], + expected_ids: tuple[str, ...], + path: str, + collector: _Collector, + expected_outcomes: Mapping[str, tuple[int, str]] | None = None, + *, + require_activated_oracle: bool = False, +) -> None: + cases = mutation.get("parameterizedCases") + if not isinstance(cases, list) or not cases: + collector.add(f"{path}.parameterizedCases", "must be a non-empty array") + return + case_ids: list[str] = [] + for index, case_value in enumerate(cases): + case = collector.require_mapping( + case_value, f"{path}.parameterizedCases[{index}]" + ) + if case is None: + continue + case_id = case.get("id") + if collector.require_nonempty_string( + case_id, f"{path}.parameterizedCases[{index}].id" + ): + assert isinstance(case_id, str) + case_ids.append(case_id) + if expected_outcomes is not None and case_id in expected_outcomes: + expected_status, expected_outcome = expected_outcomes[case_id] + if case.get("expectedStatus") != expected_status: + collector.add( + f"{path}.parameterizedCases[{index}].expectedStatus", + f"must be {expected_status} for {case_id}", + ) + if case.get("expectedOutcome") != expected_outcome: + collector.add( + f"{path}.parameterizedCases[{index}].expectedOutcome", + f"must be {expected_outcome!r} for {case_id}", + ) + mutation_value = case.get("mutation") + mutation_path = f"{path}.parameterizedCases[{index}].mutation" + if isinstance(mutation_value, str): + collector.require_nonempty_string(mutation_value, mutation_path) + elif ( + isinstance(mutation_value, bool) + or not isinstance(mutation_value, int) + or mutation_value < 0 + ): + collector.add( + mutation_path, + "must be a non-empty string or a non-negative integer", + ) + if require_activated_oracle: + oracle_path = f"{path}.parameterizedCases[{index}].activatedOracle" + oracle = case.get("activatedOracle") + if collector.require_nonempty_string(oracle, oracle_path): + assert isinstance(oracle, str) + canonical_digest = CANONICAL_ACTIVATED_ORACLE_DIGESTS.get(case_id) + oracle_digest = hashlib.sha256(oracle.encode("utf-8")).hexdigest() + if canonical_digest is not None and oracle_digest != canonical_digest: + collector.add( + oracle_path, + f"must preserve the canonical activated oracle for {case_id}", + ) + for field in ( + "expectedNewDurableEffects", + "expectedWrongOrganizationEffects", + "expectedContentWorkCalls", + ): + if case.get(field) != 0 or isinstance(case.get(field), bool): + collector.add( + f"{path}.parameterizedCases[{index}].{field}", + "must be the numeric constant 0", + ) + if len(case_ids) != len(set(case_ids)): + collector.add(f"{path}.parameterizedCases", "ids must be unique") + if tuple(case_ids) != expected_ids: + collector.add( + f"{path}.parameterizedCases", + f"ids must be the canonical ordered set {list(expected_ids)!r}", + ) + + +def _validate_fixture( + fixture: Mapping[str, Any], + path: str, + known_invariant_ids: set[str], + known_authority_refs: set[str], + collector: _Collector, +) -> tuple[str | None, tuple[str, ...]]: + collector.require_exact_fields(fixture, FIXTURE_FIELDS, path) + fixture_id = _validate_identifier(fixture.get("id"), f"{path}.id", collector) + collector.require_nonempty_string(fixture.get("title"), f"{path}.title") + if fixture.get("decisionStatus") not in {"accepted", "future_case"}: + collector.add( + f"{path}.decisionStatus", + "must be accepted or future_case; skipped and deferred are forbidden", + ) + + carrier = collector.require_mapping(fixture.get("carrier"), f"{path}.carrier") + carrier_status: object = None + if carrier is not None: + collector.require_exact_fields(carrier, CARRIER_FIELDS, f"{path}.carrier") + carrier_status = carrier.get("statusAtM0") + if carrier_status not in {"available", "unavailable", "future"}: + collector.add( + f"{path}.carrier.statusAtM0", + "must be available, unavailable, or future", + ) + expected_m0 = ( + "active_fail_closed" if carrier_status == "available" else "fail_closed" + ) + if carrier.get("m0Expectation") != expected_m0: + collector.add( + f"{path}.carrier.m0Expectation", + f"must be {expected_m0!r} when statusAtM0 is {carrier_status!r}", + ) + collector.require_nonempty_string( + carrier.get("upgradeTrigger"), f"{path}.carrier.upgradeTrigger" + ) + + setup = collector.require_mapping(fixture.get("setup"), f"{path}.setup") + if setup is not None: + collector.require_exact_fields(setup, SETUP_FIELDS, f"{path}.setup") + collector.require_string_list( + setup.get("preconditions"), f"{path}.setup.preconditions" + ) + collector.require_nonempty_object( + setup.get("trustedIdentity"), f"{path}.setup.trustedIdentity" + ) + collector.require_nonempty_object( + fixture.get("adversarialMutation"), f"{path}.adversarialMutation" + ) + adversarial_mutation = collector.require_mapping( + fixture.get("adversarialMutation"), f"{path}.adversarialMutation" + ) + if fixture_id == "ACCEPT-007" and adversarial_mutation is not None: + _validate_parameterized_case_ids( + adversarial_mutation, + TRANSPORT_CASE_IDS, + f"{path}.adversarialMutation", + collector, + TRANSPORT_CASE_OUTCOMES, + ) + if fixture_id == "ACCEPT-008" and adversarial_mutation is not None: + _validate_parameterized_case_ids( + adversarial_mutation, + WORKER_LEASE_CASE_IDS, + f"{path}.adversarialMutation", + collector, + {case_id: WORKER_LEASE_CASE_OUTCOME for case_id in WORKER_LEASE_CASE_IDS}, + ) + if fixture_id == "ACCEPT-009" and adversarial_mutation is not None: + if adversarial_mutation.get("caseRef") != "PROV-010": + collector.add( + f"{path}.adversarialMutation.caseRef", + "must be 'PROV-010' for the top-level service-account substitution", + ) + _validate_parameterized_case_ids( + adversarial_mutation, + ACL_PROOF_CASE_IDS, + f"{path}.adversarialMutation", + collector, + {case_id: ACL_PROOF_CASE_OUTCOME for case_id in ACL_PROOF_CASE_IDS}, + require_activated_oracle=True, + ) + if fixture_id == "ACCEPT-012" and adversarial_mutation is not None: + _validate_parameterized_case_ids( + adversarial_mutation, + AUDIENCE_ACTION_CASE_IDS, + f"{path}.adversarialMutation", + collector, + { + case_id: AUDIENCE_ACTION_CASE_OUTCOME + for case_id in AUDIENCE_ACTION_CASE_IDS + }, + require_activated_oracle=True, + ) + collector.require_nonempty_object(fixture.get("operation"), f"{path}.operation") + + expected = collector.require_mapping(fixture.get("expected"), f"{path}.expected") + io_counts: dict[str, int] = {} + if expected is not None: + collector.require_exact_fields(expected, EXPECTED_FIELDS, f"{path}.expected") + collector.require_nonempty_object( + expected.get("externalResponse"), f"{path}.expected.externalResponse" + ) + collector.require_nonempty_object( + expected.get("packageOrError"), f"{path}.expected.packageOrError" + ) + evidence_counts = _validate_metric_object( + expected.get("evidence"), + EVIDENCE_FIELDS, + f"{path}.expected.evidence", + collector, + ) + business_effect_counts = _validate_metric_object( + expected.get("businessEffects"), + BUSINESS_EFFECT_FIELDS, + f"{path}.expected.businessEffects", + collector, + ) + for field in ( + "unauthorizedEvidenceCount", + "unauthorizedContentBytes", + "missingContextFallbackCount", + ): + if evidence_counts.get(field) != 0: + collector.add( + f"{path}.expected.evidence.{field}", + "must be 0 for every acceptance fixture", + ) + if business_effect_counts.get("wrongOrganizationEffectCount") != 0: + collector.add( + f"{path}.expected.businessEffects.wrongOrganizationEffectCount", + "must be 0 for every acceptance fixture", + ) + io_counts = _validate_metric_object( + expected.get("io"), IO_FIELDS, f"{path}.expected.io", collector + ) + if carrier_status in {"unavailable", "future"}: + for field in IO_FIELDS: + if io_counts.get(field) != 0: + collector.add( + f"{path}.expected.io.{field}", + "must be 0 for an unavailable or future carrier", + ) + + canonical_fail_closed = CANONICAL_FAIL_CLOSED_OUTCOMES.get(fixture_id or "") + if canonical_fail_closed is not None and expected is not None: + for field, canonical_value in canonical_fail_closed.items(): + if expected.get(field) != canonical_value: + collector.add( + f"{path}.expected.{field}", + "must preserve the canonical fail-closed outcome " + f"for {fixture_id}", + ) + + if fixture_id in RUNTIME_OUTCOME_KINDS and expected is not None: + external_response = collector.require_mapping( + expected.get("externalResponse"), f"{path}.expected.externalResponse" + ) + package_or_error = collector.require_mapping( + expected.get("packageOrError"), f"{path}.expected.packageOrError" + ) + response_body = ( + collector.require_mapping( + external_response.get("body"), + f"{path}.expected.externalResponse.body", + ) + if external_response is not None + else None + ) + expected_body_kind, expected_result_kind = RUNTIME_OUTCOME_KINDS[fixture_id] + if external_response is not None and external_response.get("status") != 200: + collector.add( + f"{path}.expected.externalResponse.status", + "must be 200 for the canonical Runtime outcome", + ) + if ( + response_body is not None + and response_body.get("kind") != expected_body_kind + ): + collector.add( + f"{path}.expected.externalResponse.body.kind", + f"must be {expected_body_kind!r} for {fixture_id}", + ) + if ( + package_or_error is not None + and package_or_error.get("kind") != expected_result_kind + ): + collector.add( + f"{path}.expected.packageOrError.kind", + f"must be {expected_result_kind!r} for {fixture_id}", + ) + if fixture_id in NON_RETRYABLE_RUNTIME_FIXTURES and ( + response_body is not None and response_body.get("retryable") is not False + ): + collector.add( + f"{path}.expected.externalResponse.body.retryable", + f"must be false for {fixture_id}", + ) + if fixture_id in RESOLVED_EMPTY_RUNTIME_FIXTURES: + resolved_package = ( + collector.require_mapping( + response_body.get("package"), + f"{path}.expected.externalResponse.body.package", + ) + if response_body is not None + else None + ) + coverage = ( + collector.require_mapping( + resolved_package.get("coverage"), + f"{path}.expected.externalResponse.body.package.coverage", + ) + if resolved_package is not None + else None + ) + if ( + response_body is not None + and not collector.require_nonempty_string( + response_body.get("egressGrant"), + f"{path}.expected.externalResponse.body.egressGrant", + ) + and "egressGrant" not in response_body + ): + collector.add( + f"{path}.expected.externalResponse.body.egressGrant", + "is required for a resolved outcome", + ) + if coverage is not None and coverage.get("status") != "empty": + collector.add( + f"{path}.expected.externalResponse.body.package.coverage.status", + "must be 'empty' for a hidden or missing Acquire", + ) + if ( + coverage is not None + and coverage.get("reason") != "no_authorized_evidence" + ): + collector.add( + f"{path}.expected.externalResponse.body.package.coverage.reason", + "must be 'no_authorized_evidence' for a hidden or missing Acquire", + ) + for field in ("blocks", "evidence", "gaps"): + if resolved_package is not None and resolved_package.get(field) != []: + message = "must be empty for a resolved empty Package" + if field == "gaps": + message = ( + "must be empty because no_authorized_evidence is coverage, " + "not a Provider gap" + ) + collector.add( + f"{path}.expected.externalResponse.body.package.{field}", + message, + ) + if package_or_error is not None and ( + package_or_error.get("coverageStatus") != "empty" + ): + collector.add( + f"{path}.expected.packageOrError.coverageStatus", + "must be 'empty' for a hidden or missing Acquire", + ) + if package_or_error is not None and ( + package_or_error.get("coverageReason") != "no_authorized_evidence" + ): + collector.add( + f"{path}.expected.packageOrError.coverageReason", + "must be 'no_authorized_evidence' for a hidden or missing Acquire", + ) + if fixture_id == "ACCEPT-011": + if ( + external_response is not None + and external_response.get("timingEqualityClaimed") is not False + ): + collector.add( + f"{path}.expected.externalResponse.timingEqualityClaimed", + "must be false before the preregistered M5 timing gate", + ) + operation = collector.require_mapping( + fixture.get("operation"), f"{path}.operation" + ) + comparison_fields = ( + collector.require_string_list( + operation.get("comparisonFields"), + f"{path}.operation.comparisonFields", + ) + if operation is not None + else None + ) + if comparison_fields is not None and "timingBucket" in comparison_fields: + collector.add( + f"{path}.operation.comparisonFields", + "must not claim timing equality before the preregistered M5 gate", + ) + normalization_allowlist = ( + collector.require_string_list( + operation.get("normalizationAllowlist"), + f"{path}.operation.normalizationAllowlist", + ) + if operation is not None + else None + ) + canonical_allowlist = ( + "body.package.packageId", + "body.package.packageDigest", + "body.package.decisionRef", + "body.package.asOf", + "body.package.expiresAt", + "body.package.budgetUsage.elapsedMs", + "body.egressGrant", + "headers.X-Context-Request-Id", + ) + if ( + normalization_allowlist is not None + and tuple(normalization_allowlist) != canonical_allowlist + ): + collector.add( + f"{path}.operation.normalizationAllowlist", + "must be the canonical ordered allowlist " + f"{list(canonical_allowlist)!r}", + ) + probes = ( + collector.require_string_list( + adversarial_mutation.get("probes"), + f"{path}.adversarialMutation.probes", + ) + if adversarial_mutation is not None + else None + ) + canonical_probes = ( + "resource-cross-org", + "resource-same-org-denied", + "resource-missing", + ) + if probes is not None and tuple(probes) != canonical_probes: + collector.add( + f"{path}.adversarialMutation.probes", + "must be the canonical ordered probe set " + f"{list(canonical_probes)!r}", + ) + + invariant_refs = collector.require_string_list( + fixture.get("invariantRefs"), f"{path}.invariantRefs" + ) + mapping_refs: tuple[str, ...] = tuple(invariant_refs or ()) + if invariant_refs is not None: + for index, ref in enumerate(invariant_refs): + if ref not in known_invariant_ids: + collector.add( + f"{path}.invariantRefs[{index}]", f"unknown invariant {ref!r}" + ) + _validate_authority_refs( + fixture.get("authorityRefs"), + f"{path}.authorityRefs", + known_authority_refs, + collector, + ) + return fixture_id, mapping_refs + + +def _validate_fixtures( + catalog: Mapping[str, Any], + known_invariant_ids: set[str], + known_authority_refs: set[str], + collector: _Collector, +) -> tuple[tuple[str, tuple[str, ...]], ...]: + fixtures = catalog.get("fixtures") + if not isinstance(fixtures, list): + collector.add("fixtures", "must be an array") + return () + if len(fixtures) != EXPECTED_FIXTURE_COUNT: + collector.add("fixtures", "must contain exactly 12 entries") + + mappings: list[tuple[str, tuple[str, ...]]] = [] + fixture_ids: list[str] = [] + for index, value in enumerate(fixtures): + path = f"fixtures[{index}]" + fixture = collector.require_mapping(value, path) + if fixture is None: + continue + fixture_id, refs = _validate_fixture( + fixture, path, known_invariant_ids, known_authority_refs, collector + ) + if fixture_id is not None: + fixture_ids.append(fixture_id) + mappings.append((fixture_id, refs)) + + seen: set[str] = set() + for fixture_id in fixture_ids: + if fixture_id in seen: + collector.add("fixtures", f"duplicate id {fixture_id!r}") + seen.add(fixture_id) + if tuple(fixture_ids) != CANONICAL_FIXTURE_IDS: + collector.add( + "fixtures", + "ids must be the canonical ordered set: " + + ", ".join(CANONICAL_FIXTURE_IDS), + ) + return tuple(sorted(mappings)) + + +def _resolve_schema_node( + schema: Mapping[str, Any], node: object, path: str, collector: _Collector +) -> Mapping[str, Any] | None: + mapping = collector.require_mapping(node, path) + if mapping is None: + return None + reference = mapping.get("$ref") + if reference is None: + return mapping + if not isinstance(reference, str) or not reference.startswith("#/"): + collector.add(path, "must use a local JSON Pointer $ref") + return None + current: object = schema + for part in reference[2:].split("/"): + part = part.replace("~1", "/").replace("~0", "~") + if not isinstance(current, Mapping) or part not in current: + collector.add(path, f"unresolvable schema reference {reference!r}") + return None + current = current[part] + resolved = collector.require_mapping( + current, + reference, + ) + return resolved + + +def _json_values_equal(left: object, right: object) -> bool: + """Compare JSON values without treating booleans as integers.""" + + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + if isinstance(left, int | float) and isinstance(right, int | float): + return left == right + return type(left) is type(right) and left == right + + +def _matches_json_type(value: object, expected_type: str) -> bool: + match expected_type: + case "object": + return isinstance(value, dict) + case "array": + return isinstance(value, list) + case "string": + return isinstance(value, str) + case "integer": + return isinstance(value, int) and not isinstance(value, bool) + case "number": + return isinstance(value, int | float) and not isinstance(value, bool) + case "boolean": + return isinstance(value, bool) + case "null": + return value is None + case _: + return False + + +def _validate_schema_instance( + value: object, + node: object, + root_schema: Mapping[str, Any], + path: str, + collector: _Collector, +) -> None: + """Apply the Draft 2020-12 keywords used by the tracked catalog schema.""" + + if node is False: + collector.add(path, "is forbidden by the schema") + return + if node is True: + return + schema_node = collector.require_mapping(node, f"schema for {path}") + if schema_node is None: + return + + reference = schema_node.get("$ref") + if reference is not None: + resolved = _resolve_schema_node( + root_schema, schema_node, f"schema for {path}", collector + ) + if resolved is None: + return + _validate_schema_instance(value, resolved, root_schema, path, collector) + remaining = {key: item for key, item in schema_node.items() if key != "$ref"} + if remaining: + _validate_schema_instance(value, remaining, root_schema, path, collector) + return + + expected_types = schema_node.get("type") + if isinstance(expected_types, str): + expected_type_names = (expected_types,) + elif isinstance(expected_types, list) and all( + isinstance(entry, str) for entry in expected_types + ): + expected_type_names = tuple(expected_types) + else: + expected_type_names = () + if expected_type_names and not any( + _matches_json_type(value, expected_type) + for expected_type in expected_type_names + ): + rendered = " or ".join(repr(name) for name in expected_type_names) + collector.add(path, f"must have type {rendered}") + return + + if "const" in schema_node and not _json_values_equal(value, schema_node["const"]): + collector.add(path, f"must equal {schema_node['const']!r}") + enum = schema_node.get("enum") + if isinstance(enum, list) and not any( + _json_values_equal(value, item) for item in enum + ): + collector.add(path, "must be one of the schema's enumerated values") + + if isinstance(value, str): + minimum_length = schema_node.get("minLength") + if isinstance(minimum_length, int) and len(value) < minimum_length: + collector.add(path, f"must contain at least {minimum_length} characters") + pattern = schema_node.get("pattern") + if isinstance(pattern, str) and re.search(pattern, value) is None: + collector.add(path, f"must match schema pattern {pattern!r}") + + if isinstance(value, int | float) and not isinstance(value, bool): + minimum = schema_node.get("minimum") + maximum = schema_node.get("maximum") + if isinstance(minimum, int | float) and value < minimum: + collector.add(path, f"must be greater than or equal to {minimum}") + if isinstance(maximum, int | float) and value > maximum: + collector.add(path, f"must be less than or equal to {maximum}") + + if isinstance(value, list): + minimum_items = schema_node.get("minItems") + maximum_items = schema_node.get("maxItems") + if isinstance(minimum_items, int) and len(value) < minimum_items: + collector.add(path, f"must contain at least {minimum_items} items") + if isinstance(maximum_items, int) and len(value) > maximum_items: + collector.add(path, f"must contain no more than {maximum_items} items") + if schema_node.get("uniqueItems") is True: + encoded = [ + json.dumps( + item, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ) + for item in value + ] + if len(encoded) != len(set(encoded)): + collector.add(path, "must contain unique items") + prefix_items = schema_node.get("prefixItems") + prefix_count = 0 + if isinstance(prefix_items, list): + prefix_count = min(len(value), len(prefix_items)) + for index in range(prefix_count): + _validate_schema_instance( + value[index], + prefix_items[index], + root_schema, + f"{path}[{index}]", + collector, + ) + item_schema = schema_node.get("items") + if item_schema is not None: + for index in range(prefix_count, len(value)): + _validate_schema_instance( + value[index], + item_schema, + root_schema, + f"{path}[{index}]", + collector, + ) + + if isinstance(value, dict): + minimum_properties = schema_node.get("minProperties") + if isinstance(minimum_properties, int) and len(value) < minimum_properties: + collector.add( + path, f"must contain at least {minimum_properties} properties" + ) + required = schema_node.get("required") + if isinstance(required, list): + for field in required: + if isinstance(field, str) and field not in value: + collector.add(f"{path}.{field}", "is required by the schema") + properties = schema_node.get("properties") + if isinstance(properties, Mapping): + for field, child_schema in properties.items(): + if field in value: + _validate_schema_instance( + value[field], + child_schema, + root_schema, + f"{path}.{field}", + collector, + ) + if schema_node.get("additionalProperties") is False: + for field in value: + if field not in properties: + collector.add(f"{path}.{field}", "is not allowed by the schema") + + condition = schema_node.get("if") + if condition is not None: + probe = _Collector() + _validate_schema_instance(value, condition, root_schema, path, probe) + branch = ( + schema_node.get("then") if not probe.errors else schema_node.get("else") + ) + if branch is not None: + _validate_schema_instance(value, branch, root_schema, path, collector) + + +def _schema_properties( + node: Mapping[str, Any], path: str, collector: _Collector +) -> Mapping[str, Any] | None: + return collector.require_mapping(node.get("properties"), f"{path}.properties") + + +def _require_schema_fields( + node: Mapping[str, Any], fields: Sequence[str], path: str, collector: _Collector +) -> None: + required = node.get("required") + if not isinstance(required, list): + collector.add(f"{path}.required", "must be an array") + return + for field in fields: + if field not in required: + collector.add(f"{path}.required", f"must declare {field!r}") + + +def _require_closed_object_schema( + node: Mapping[str, Any], fields: Sequence[str], path: str, collector: _Collector +) -> None: + if node.get("type") != "object": + collector.add(f"{path}.type", "must be 'object'") + if node.get("additionalProperties") is not False: + collector.add(f"{path}.additionalProperties", "must be false") + _require_schema_fields(node, fields, path, collector) + + +def _schema_child( + schema: Mapping[str, Any], + node: Mapping[str, Any], + child_name: str, + path: str, + collector: _Collector, +) -> Mapping[str, Any] | None: + properties = _schema_properties(node, path, collector) + if properties is None or child_name not in properties: + collector.add(f"{path}.properties.{child_name}", "is required") + return None + return _resolve_schema_node( + schema, properties[child_name], f"{path}.properties.{child_name}", collector + ) + + +def _validate_schema( + schema: object, catalog_version: object, collector: _Collector +) -> None: + root = collector.require_mapping(schema, "schema") + if root is None: + return + if root.get("type") != "object": + collector.add("schema.type", "must be 'object'") + if root.get("additionalProperties") is not False: + collector.add("schema.additionalProperties", "must be false") + _require_schema_fields(root, TOP_LEVEL_FIELDS, "schema", collector) + if root.get("$schema") != "https://json-schema.org/draft/2020-12/schema": + collector.add("schema.$schema", "must declare Draft 2020-12") + + version_schema = _schema_child(root, root, "catalogVersion", "schema", collector) + if version_schema is not None and version_schema.get("const") != catalog_version: + collector.add( + "schema.properties.catalogVersion.const", + "must equal the catalog's catalogVersion", + ) + + hard_oracle_schema = _schema_child(root, root, "hardOracles", "schema", collector) + if hard_oracle_schema is not None: + if hard_oracle_schema.get("type") != "array": + collector.add("schema.properties.hardOracles.type", "must be 'array'") + for keyword in ("minItems", "maxItems"): + if hard_oracle_schema.get(keyword) != 3: + collector.add(f"schema.properties.hardOracles.{keyword}", "must be 3") + prefix_items = hard_oracle_schema.get("prefixItems") + if not isinstance(prefix_items, list) or len(prefix_items) != 3: + collector.add( + "schema.properties.hardOracles.prefixItems", + "must freeze exactly 3 hard-oracle entries", + ) + else: + for index, expected_name in enumerate(HARD_ORACLES): + item = _resolve_schema_node( + root, + prefix_items[index], + f"schema.properties.hardOracles.prefixItems[{index}]", + collector, + ) + if item is None: + continue + _require_closed_object_schema( + item, + ("name", "requiredValue", "veto"), + f"schema.properties.hardOracles.prefixItems[{index}]", + collector, + ) + properties = _schema_properties( + item, + f"schema.properties.hardOracles.prefixItems[{index}]", + collector, + ) + if properties is None: + continue + expected_constants = ( + ("name", expected_name, str), + ("requiredValue", 0, int), + ("veto", True, bool), + ) + for field, expected_constant, expected_type in expected_constants: + field_schema = properties.get(field) + if ( + not isinstance(field_schema, Mapping) + or type(field_schema.get("const")) is not expected_type + or field_schema.get("const") != expected_constant + ): + collector.add( + f"schema.properties.hardOracles.prefixItems[{index}].properties.{field}.const", + f"must be {expected_constant!r}", + ) + if hard_oracle_schema.get("items") is not False: + collector.add("schema.properties.hardOracles.items", "must be false") + + array_specs = ( + ("invariants", EXPECTED_INVARIANT_COUNT, INVARIANT_FIELDS), + ("fixtures", EXPECTED_FIXTURE_COUNT, FIXTURE_FIELDS), + ) + item_nodes: dict[str, Mapping[str, Any]] = {} + for name, count, required_fields in array_specs: + array_schema = _schema_child(root, root, name, "schema", collector) + if array_schema is None: + continue + if array_schema.get("type") != "array": + collector.add(f"schema.properties.{name}.type", "must be 'array'") + for keyword in ("minItems", "maxItems"): + if array_schema.get(keyword) != count: + collector.add(f"schema.properties.{name}.{keyword}", f"must be {count}") + if array_schema.get("uniqueItems") is not True: + collector.add(f"schema.properties.{name}.uniqueItems", "must be true") + item = _resolve_schema_node( + root, + array_schema.get("items"), + f"schema.properties.{name}.items", + collector, + ) + if item is not None: + _require_closed_object_schema( + item, required_fields, f"schema.{name}.items", collector + ) + item_nodes[name] = item + + invariant_item = item_nodes.get("invariants") + if invariant_item is not None: + invariant_id_schema = _schema_child( + root, invariant_item, "id", "schema.invariants.items", collector + ) + if ( + invariant_id_schema is not None + and tuple(invariant_id_schema.get("enum", ())) != CANONICAL_INVARIANT_IDS + ): + collector.add( + "schema.invariants.items.properties.id.enum", + "must freeze the canonical ordered IDs", + ) + for child, fields in ( + ("applicability", ("mode", "applicableFrom", "rationale")), + ("expectedEvidence", EXPECTED_EVIDENCE_FIELDS), + ): + child_schema = _schema_child( + root, invariant_item, child, "schema.invariants.items", collector + ) + if child_schema is not None: + _require_closed_object_schema( + child_schema, + fields, + f"schema.invariants.items.properties.{child}", + collector, + ) + + fixture_item = item_nodes.get("fixtures") + if fixture_item is not None: + fixture_id_schema = _schema_child( + root, fixture_item, "id", "schema.fixtures.items", collector + ) + if ( + fixture_id_schema is not None + and tuple(fixture_id_schema.get("enum", ())) != CANONICAL_FIXTURE_IDS + ): + collector.add( + "schema.fixtures.items.properties.id.enum", + "must freeze the canonical ordered IDs", + ) + for child, fields in (("carrier", CARRIER_FIELDS), ("setup", SETUP_FIELDS)): + child_schema = _schema_child( + root, fixture_item, child, "schema.fixtures.items", collector + ) + if child_schema is not None: + _require_closed_object_schema( + child_schema, + fields, + f"schema.fixtures.items.properties.{child}", + collector, + ) + expected_schema = _schema_child( + root, fixture_item, "expected", "schema.fixtures.items", collector + ) + if expected_schema is not None: + _require_closed_object_schema( + expected_schema, + EXPECTED_FIELDS, + "schema.fixtures.items.properties.expected", + collector, + ) + for child, fields in ( + ("evidence", EVIDENCE_FIELDS), + ("businessEffects", BUSINESS_EFFECT_FIELDS), + ("io", IO_FIELDS), + ): + child_schema = _schema_child( + root, + expected_schema, + child, + "schema.fixtures.items.properties.expected", + collector, + ) + if child_schema is not None: + _require_closed_object_schema( + child_schema, + fields, + f"schema.fixtures.items.properties.expected.properties.{child}", + collector, + ) + + definitions = root.get("$defs") + definitions = collector.require_mapping(definitions, "schema.$defs") + if definitions is not None: + closed_object_definitions = { + "authority": ("issueRefs", "documentRefs", "reconciliation"), + "applicability": ("mode", "applicableFrom", "rationale"), + "expectedEvidence": EXPECTED_EVIDENCE_FIELDS, + "carrier": CARRIER_FIELDS, + "setup": SETUP_FIELDS, + "trustedIdentity": (), + "invocationIdentity": ("organizationRef", "principalRef", "purpose"), + "adversarialMutation": ("kind",), + "probeAttempt": ("invocation", "target"), + "requestNarrowing": ("sourceRefs",), + "injectedBodyFields": ( + "organizationRef", + "principalRef", + "purpose", + "audience", + "acl", + "rawSql", + "bypassAuthorization", + ), + "mutatedClaim": (), + "parameterizedCase": PARAMETERIZED_CASE_FIELDS, + "operation": ("interface", "request"), + "externalResponse": ("status",), + "responseBody": (), + "packageOrError": ("kind",), + "evidenceMetrics": EVIDENCE_FIELDS, + "businessEffectMetrics": BUSINESS_EFFECT_FIELDS, + "ioMetrics": IO_FIELDS, + "expected": EXPECTED_FIELDS, + "invariant": INVARIANT_FIELDS, + "fixture": FIXTURE_FIELDS, + } + for definition_name, required_fields in closed_object_definitions.items(): + definition = definitions.get(definition_name) + definition_path = f"schema.$defs.{definition_name}" + if definition is None: + continue + definition_mapping = collector.require_mapping(definition, definition_path) + if definition_mapping is None: + continue + if definition_mapping.get("type") != "object": + collector.add(f"{definition_path}.type", "must be 'object'") + if definition_mapping.get("additionalProperties") is not False: + collector.add( + f"{definition_path}.additionalProperties", "must be false" + ) + if required_fields: + _require_schema_fields( + definition_mapping, required_fields, definition_path, collector + ) + + invariant_id_value = definitions.get("invariantId") + invariant_id = ( + collector.require_mapping(invariant_id_value, "schema.$defs.invariantId") + if invariant_id_value is not None + else None + ) + if ( + invariant_id is not None + and tuple(invariant_id.get("enum", ())) != CANONICAL_INVARIANT_IDS + ): + collector.add( + "schema.$defs.invariantId.enum", + "must freeze the canonical ordered IDs", + ) + fixture_id_value = definitions.get("fixtureId") + fixture_id = ( + collector.require_mapping(fixture_id_value, "schema.$defs.fixtureId") + if fixture_id_value is not None + else None + ) + if ( + fixture_id is not None + and tuple(fixture_id.get("enum", ())) != CANONICAL_FIXTURE_IDS + ): + collector.add( + "schema.$defs.fixtureId.enum", + "must freeze the canonical ordered IDs", + ) + + +def validate_catalog( + catalog: Mapping[str, Any], schema: Mapping[str, Any] +) -> ValidationReport: + """Validate a catalog and schema together, returning report-ready facts.""" + + collector = _Collector() + collector.require_exact_fields(catalog, TOP_LEVEL_FIELDS, "catalog") + collector.require_nonempty_string(catalog.get("catalogVersion"), "catalogVersion") + if catalog.get("catalogVersion") != SUPPORTED_CATALOG_VERSION: + collector.add( + "catalogVersion", + f"must be the supported version {SUPPORTED_CATALOG_VERSION!r}", + ) + known_authority_refs = _validate_authority(catalog, collector) + _validate_hard_oracles(catalog, collector) + invariant_ids = _validate_invariants(catalog, known_authority_refs, collector) + mappings = _validate_fixtures( + catalog, invariant_ids, known_authority_refs, collector + ) + _validate_schema(schema, catalog.get("catalogVersion"), collector) + if isinstance(schema, Mapping): + _validate_schema_instance(catalog, schema, schema, "catalog", collector) + if collector.errors: + raise CatalogValidationError(collector.errors) + + return ValidationReport( + invariant_count=len(catalog["invariants"]), + fixture_count=len(catalog["fixtures"]), + fixture_mappings=mappings, + ) + + +_MARKDOWN_HEADING = re.compile(r"^ {0,3}#{1,6}(?:[ \t]+|$)(.*)$") +_MARKDOWN_CLOSING_HASHES = re.compile(r"[ \t]+#+[ \t]*$") +_MARKDOWN_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})") +_MARKDOWN_LINK = re.compile(r"!?\[([^]]*)\]\([^)]*\)") +_MARKDOWN_HTML_TAG = re.compile(r"<[^>]+>") + + +def _github_heading_slug(heading: str) -> str: + """Return the GitHub-style base slug for a Markdown heading.""" + + visible_text = _MARKDOWN_LINK.sub(r"\1", heading) + visible_text = _MARKDOWN_HTML_TAG.sub("", visible_text) + visible_text = html.unescape(visible_text).lower() + slug_characters = ( + character + for character in visible_text + if character.isalnum() or character.isspace() or character in {"-", "_"} + ) + return re.sub(r"\s", "-", "".join(slug_characters)) + + +def _markdown_heading_anchors(document: Path) -> set[str]: + """Extract GitHub-style anchors, including deterministic duplicate suffixes.""" + + anchors: set[str] = set() + fence_character: str | None = None + fence_length = 0 + for line in document.read_text(encoding="utf-8").splitlines(): + fence = _MARKDOWN_FENCE.match(line) + if fence is not None: + marker = fence.group(1) + if fence_character is None: + fence_character = marker[0] + fence_length = len(marker) + elif marker[0] == fence_character and len(marker) >= fence_length: + fence_character = None + fence_length = 0 + continue + if fence_character is not None: + continue + + match = _MARKDOWN_HEADING.match(line) + if match is None: + continue + heading = _MARKDOWN_CLOSING_HASHES.sub("", match.group(1)).strip() + base_slug = _github_heading_slug(heading) + if not base_slug: + continue + anchor = base_slug + suffix = 0 + while anchor in anchors: + suffix += 1 + anchor = f"{base_slug}-{suffix}" + anchors.add(anchor) + return anchors + + +def _git_tracks(repository_root: Path, ref: str) -> bool: + """Return whether *ref* is present in the repository's Git index.""" + + try: + result = subprocess.run( + [ + "git", + "-C", + str(repository_root), + "ls-files", + "--error-unmatch", + "--", + ref, + ], + check=False, + capture_output=True, + text=True, + ) + except OSError: + return False + return result.returncode == 0 + + +def _iter_catalog_authority_refs( + catalog: Mapping[str, Any], +) -> Sequence[tuple[str, str]]: + refs: list[tuple[str, str]] = [] + for collection_name in ("invariants", "fixtures"): + collection = catalog.get(collection_name) + if not isinstance(collection, list): + continue + for item_index, item in enumerate(collection): + if not isinstance(item, Mapping): + continue + authority_refs = item.get("authorityRefs") + if not isinstance(authority_refs, list): + continue + for ref_index, ref in enumerate(authority_refs): + if isinstance(ref, str): + refs.append( + ( + f"{collection_name}[{item_index}].authorityRefs[{ref_index}]", + ref, + ) + ) + return refs + + +def _validate_document_paths(catalog: Mapping[str, Any], repository_root: Path) -> None: + errors: list[str] = [] + authority = catalog.get("authority") + if not isinstance(authority, Mapping): + return + document_refs = authority.get("documentRefs") + if not isinstance(document_refs, list): + return + root = repository_root.resolve() + tracked_documents: dict[str, Path] = {} + for index, ref in enumerate(document_refs): + if not isinstance(ref, str): + continue + path = f"authority.documentRefs[{index}]" + ref_path = Path(ref) + if ref_path.is_absolute() or ".." in ref_path.parts: + errors.append(f"{path}: must be a repository-relative path without '..'") + continue + resolved = (root / ref_path).resolve() + try: + resolved.relative_to(root) + except ValueError: + errors.append(f"{path}: must resolve inside the repository") + continue + if not resolved.is_file(): + errors.append(f"{path}: tracked document does not exist: {ref!r}") + continue + if not _git_tracks(root, ref): + errors.append(f"{path}: must reference a Git-tracked file: {ref!r}") + continue + tracked_documents[ref] = resolved + + heading_anchors: dict[str, set[str]] = {} + for ref_path, ref in _iter_catalog_authority_refs(catalog): + document_ref, separator, fragment = ref.partition("#") + if not document_ref or not separator: + # Bare references such as issue ``#5`` are not document anchors. + continue + document = tracked_documents.get(document_ref) + if document is None or document.suffix.lower() not in {".md", ".markdown"}: + continue + anchors = heading_anchors.get(document_ref) + if anchors is None: + try: + anchors = _markdown_heading_anchors(document) + except (OSError, UnicodeError): + # Existence/tracking errors above remain the actionable boundary. + continue + heading_anchors[document_ref] = anchors + if unquote(fragment) not in anchors: + errors.append( + f"{ref_path}: Markdown heading anchor does not exist: {ref!r}" + ) + if errors: + raise CatalogValidationError(errors) + + +def validate_files( + catalog_path: str | Path = DEFAULT_CATALOG_PATH, + schema_path: str | Path = DEFAULT_SCHEMA_PATH, + *, + repository_root: str | Path = REPOSITORY_ROOT, +) -> ValidationReport: + """Load and validate the tracked catalog and schema files.""" + + catalog = load_document(catalog_path) + report = validate_catalog(catalog, load_document(schema_path)) + _validate_document_paths(catalog, Path(repository_root)) + return report + + +def render_report(report: ValidationReport) -> str: + """Render the count and complete fixture-to-invariant mapping evidence.""" + + lines = [ + ( + "security catalog valid: " + f"{report.invariant_count} invariants, {report.fixture_count} fixtures" + ), + "fixture -> invariants:", + ] + for fixture_id, invariant_refs in report.fixture_mappings: + lines.append(f" {fixture_id}: {', '.join(invariant_refs)}") + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "catalog", + nargs="?", + type=Path, + default=DEFAULT_CATALOG_PATH, + help=( + "catalog path (default: repository eval/catalogs/security-invariants.yaml)" + ), + ) + parser.add_argument( + "--schema", + type=Path, + default=DEFAULT_SCHEMA_PATH, + help=( + "schema path (default: repository " + "eval/catalogs/security-catalog.schema.json)" + ), + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + report = validate_files(args.catalog, args.schema) + except CatalogValidationError as error: + print("security catalog invalid:", file=sys.stderr) + for message in error.errors: + print(f" - {message}", file=sys.stderr) + return 1 + print(render_report(report)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/catalog/test_validate_security_catalog.py b/tests/catalog/test_validate_security_catalog.py new file mode 100644 index 00000000..737f3c7a --- /dev/null +++ b/tests/catalog/test_validate_security_catalog.py @@ -0,0 +1,1517 @@ +from __future__ import annotations + +import contextlib +import copy +import io +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from typing import Any, cast + +from scripts.validate_security_catalog import ( + ACL_PROOF_CASE_IDS, + AUDIENCE_ACTION_CASE_IDS, + CANONICAL_FAIL_CLOSED_OUTCOMES, + CANONICAL_INVARIANT_IDS, + DEFAULT_CATALOG_PATH, + DEFAULT_SCHEMA_PATH, + REQUIRED_RUNTIME_EVIDENCE, + CatalogValidationError, + load_document, + main, + render_report, + validate_catalog, + validate_files, +) + +HARD_ORACLE_NAMES = ( + "Unauthorized Evidence", + "wrong-Organization effect", + "missing-context fallback", +) + +CANONICAL_REQUIRED_MILESTONES = { + "TENANT-OWNERSHIP-001": ["M0", "M1"], + "TENANT-FK-002": ["M0", "M1"], + "RLS-FAIL-CLOSED-003": ["M0"], + "SCOPE-INTERSECTION-004": ["M0", "M1", "M5"], + "INDEX-NOT-AUTHORITY-005": ["M0", "M1", "M3"], + "REVOCATION-006": ["M1", "M2"], + "WORKER-LEASE-007": ["M1", "M3"], + "TRANSPORT-UNTRUSTED-008": ["M1", "M2"], + "NON-ENUMERATION-009": ["M1", "M5"], + "CITATION-AUTH-010": ["M2", "M3"], + "EGRESS-011": ["M2", "M5"], + "TRACE-REDACTION-012": ["M0", "M1"], + "ACTION-SEPARATION-014": ["M2"], + "CROSS-ORG-LEARN-015": ["M0", "M3"], + "RELEASE-OWNER-019": ["M0", "M3"], +} + +RUNTIME_OUTCOMES = { + "ACCEPT-001": ("resolved", "ContextPackage"), + "ACCEPT-005": ("request_not_available", "request_not_available"), + "ACCEPT-009": ("request_not_available", "request_not_available"), + "ACCEPT-010": ("citation_not_available", "citation_not_available"), + "ACCEPT-011": ("resolved", "ContextPackage"), +} + +TRANSPORT_CASE_IDS = [ + "BODY-INJECTION", + "DELIV-001", + "DELIV-002", + "DELIV-003", + "DELIV-004", +] +WORKER_LEASE_CASE_IDS = [ + "LEASE-ORGANIZATION", + "LEASE-JOB", + "LEASE-OPERATION", + "LEASE-SOURCE", + "LEASE-RESOURCE", + "LEASE-REVISION", + "LEASE-SERVICE-ACTOR", + "LEASE-WORKLOAD", + "LEASE-POLICY-EPOCH", + "LEASE-AUDIENCE", + "LEASE-IDEMPOTENCY", + "LEASE-GENERATION", + "LEASE-ISSUED-AT", + "LEASE-EXPIRY", + "LEASE-NONCE", + "LEASE-REPLAY", + "LEASE-USER-IMPERSONATION", +] + + +def object_at(mapping: dict[str, object], *keys: str) -> dict[str, object]: + """Return a nested object while keeping malformed test data type-safe.""" + current: object = mapping + for key in keys: + assert isinstance(current, dict) + current = current[key] + assert isinstance(current, dict) + return cast(dict[str, object], current) + + +def object_list_at(mapping: dict[str, object], *keys: str) -> list[dict[str, object]]: + """Return a nested list of objects with runtime shape assertions.""" + current: object = mapping + for key in keys: + assert isinstance(current, dict) + current = current[key] + assert isinstance(current, list) + assert all(isinstance(item, dict) for item in current) + return cast(list[dict[str, object]], current) + + +def make_catalog() -> dict[str, object]: + invariants = [] + for number, invariant_id in enumerate(CANONICAL_INVARIANT_IDS, start=1): + required_milestones = CANONICAL_REQUIRED_MILESTONES[invariant_id] + invariants.append( + { + "id": invariant_id, + "title": f"Invariant {number}", + "purpose": "Make the security boundary deterministic.", + "threatRefs": ["TM-01"], + "protectedAssets": ["A-01"], + "deterministicOracle": "The prohibited observation is exactly zero.", + "hardOracleRefs": [HARD_ORACLE_NAMES[(number - 1) % 3]], + "applicability": { + "mode": "required", + "applicableFrom": required_milestones[0], + "rationale": None, + }, + "capabilityRef": "tenant-isolation", + "requiredMilestones": required_milestones, + "evidenceStatus": "accepted", + "expectedEvidence": { + "property": [f"PROP-{number:03d}"], + "postgres": [f"PG-{number:03d}"], + "runtimeOrDelivery": [ + f"RUNTIME-{number:03d}", + *REQUIRED_RUNTIME_EVIDENCE.get(invariant_id, ()), + ], + }, + "authorityRefs": [ + "docs/security/context-engine-threat-model.md#5-hard-oracles" + ], + } + ) + + fixtures = [] + for number in range(1, 13): + fixture_id = f"ACCEPT-{number:03d}" + external_response: dict[str, object] = { + "status": 404, + "body": "generic", + } + package_or_error: dict[str, object] = { + "kind": "error", + "code": "not_found", + } + operation: dict[str, object] = {"kind": "resolve"} + adversarial_mutation: dict[str, object] = { + "kind": "cross_organization_reference" + } + if fixture_id in RUNTIME_OUTCOMES: + body_kind, result_kind = RUNTIME_OUTCOMES[fixture_id] + body: dict[str, object] = {"kind": body_kind} + external_response = {"status": 200, "body": body} + package_or_error = {"kind": result_kind, "code": "domain_outcome"} + if fixture_id in {"ACCEPT-001", "ACCEPT-011"}: + body["package"] = { + "packageId": "opaque-package-ref", + "packageDigest": "sha256-package-digest", + "purpose": "context.answer", + "audienceDigest": "audience-bound-digest", + "policyEpoch": "current-policy-epoch", + "decisionRef": "opaque-decision-ref", + "releaseManifestRef": "active-release-manifest-ref", + "retentionPolicyRef": "active-retention-policy-ref", + "asOf": "current-rfc3339-time", + "expiresAt": "bounded-rfc3339-expiry", + "tokenizerRef": "active-tokenizer-ref", + "blocks": [], + "evidence": [], + "gaps": [], + "coverage": { + "status": "empty", + "reason": "no_authorized_evidence", + }, + "budgetUsage": { + "tokens": 0, + "providerCalls": 0, + "costMicrounits": 0, + "elapsedMs": 0, + }, + } + body["egressGrant"] = "opaque-matching-egress-grant" + package_or_error["coverageStatus"] = "empty" + package_or_error["coverageReason"] = "no_authorized_evidence" + if fixture_id in {"ACCEPT-005", "ACCEPT-009"}: + body["retryable"] = False + if fixture_id == "ACCEPT-011": + external_response["timingEqualityClaimed"] = False + operation["comparisonFields"] = ["status", "body", "headers"] + operation["normalizationAllowlist"] = [ + "body.package.packageId", + "body.package.packageDigest", + "body.package.decisionRef", + "body.package.asOf", + "body.package.expiresAt", + "body.package.budgetUsage.elapsedMs", + "body.egressGrant", + "headers.X-Context-Request-Id", + ] + adversarial_mutation["probes"] = [ + "resource-cross-org", + "resource-same-org-denied", + "resource-missing", + ] + if fixture_id in {"ACCEPT-007", "ACCEPT-008"}: + case_ids = ( + TRANSPORT_CASE_IDS + if fixture_id == "ACCEPT-007" + else WORKER_LEASE_CASE_IDS + ) + adversarial_mutation["parameterizedCases"] = [ + { + "id": case_id, + "mutation": "Mutate exactly the named trust binding.", + "expectedStatus": ( + 422 + if case_id == "BODY-INJECTION" + else 200 + if fixture_id == "ACCEPT-007" + else 404 + ), + "expectedOutcome": ( + "invalid_request" + if case_id == "BODY-INJECTION" + else "request_not_available" + if fixture_id == "ACCEPT-007" + else "work_not_available" + ), + "expectedNewDurableEffects": 0, + "expectedWrongOrganizationEffects": 0, + "expectedContentWorkCalls": 0, + } + for case_id in case_ids + ] + if fixture_id in {"ACCEPT-009", "ACCEPT-012"}: + tracked_catalog = load_document(DEFAULT_CATALOG_PATH) + tracked_oracles: dict[str, str] = {} + for tracked_fixture in cast( + list[dict[str, Any]], tracked_catalog["fixtures"] + ): + if tracked_fixture["id"] not in {"ACCEPT-009", "ACCEPT-012"}: + continue + tracked_cases = cast( + list[dict[str, Any]], + tracked_fixture["adversarialMutation"]["parameterizedCases"], + ) + tracked_oracles.update( + { + cast(str, case["id"]): cast(str, case["activatedOracle"]) + for case in tracked_cases + } + ) + derived_case_ids = ( + ACL_PROOF_CASE_IDS + if fixture_id == "ACCEPT-009" + else AUDIENCE_ACTION_CASE_IDS + ) + adversarial_mutation["caseRef"] = ( + "PROV-010" if fixture_id == "ACCEPT-009" else "ACTION-001" + ) + adversarial_mutation["parameterizedCases"] = [ + { + "id": case_id, + "mutation": "Exercise the named derived security obligation.", + "expectedStatus": 200 if fixture_id == "ACCEPT-009" else 404, + "expectedOutcome": ( + "request_not_available" + if fixture_id == "ACCEPT-009" + else "action_not_available" + ), + "expectedNewDurableEffects": 0, + "expectedWrongOrganizationEffects": 0, + "expectedContentWorkCalls": 0, + "activatedOracle": tracked_oracles[case_id], + } + for case_id in derived_case_ids + ] + if fixture_id in CANONICAL_FAIL_CLOSED_OUTCOMES: + canonical_outcome = copy.deepcopy( + CANONICAL_FAIL_CLOSED_OUTCOMES[fixture_id] + ) + external_response = cast( + dict[str, object], canonical_outcome["externalResponse"] + ) + package_or_error = cast( + dict[str, object], canonical_outcome["packageOrError"] + ) + fixtures.append( + { + "id": fixture_id, + "title": f"Acceptance fixture {number}", + "decisionStatus": "accepted", + "carrier": { + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Upgrade when the complete carrier is activated.", + }, + "setup": { + "preconditions": ["Two isolated Organizations exist."], + "trustedIdentity": {"kind": "authenticated_invocation"}, + }, + "adversarialMutation": adversarial_mutation, + "operation": operation, + "expected": { + "externalResponse": external_response, + "packageOrError": package_or_error, + "evidence": { + "unauthorizedEvidenceCount": 0, + "unauthorizedContentBytes": 0, + "missingContextFallbackCount": 0, + "outboundBytes": 0, + }, + "businessEffects": { + "wrongOrganizationEffectCount": 0, + "mutationEffectCount": 0, + "totalEffectsAfterScenario": 0, + }, + "io": { + "providerCalls": 0, + "indexCalls": 0, + "modelCalls": 0, + "actionCalls": 0, + }, + }, + "invariantRefs": [CANONICAL_INVARIANT_IDS[(number - 1) % 15]], + "authorityRefs": [ + "#5", + "docs/security/context-engine-threat-model.md#5-hard-oracles", + ], + } + ) + + return { + "catalogVersion": "1.0.0", + "authority": { + "issueRefs": ["#5"], + "documentRefs": ["docs/security/context-engine-threat-model.md"], + "reconciliation": "Accepted decisions take precedence.", + }, + "hardOracles": [ + {"name": name, "requiredValue": 0, "veto": True} + for name in HARD_ORACLE_NAMES + ], + "invariants": invariants, + "fixtures": fixtures, + } + + +def make_schema() -> dict[str, object]: + invariant_required = [ + "id", + "title", + "purpose", + "threatRefs", + "protectedAssets", + "deterministicOracle", + "hardOracleRefs", + "applicability", + "capabilityRef", + "requiredMilestones", + "evidenceStatus", + "expectedEvidence", + "authorityRefs", + ] + fixture_required = [ + "id", + "title", + "decisionStatus", + "carrier", + "setup", + "adversarialMutation", + "operation", + "expected", + "invariantRefs", + "authorityRefs", + ] + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": False, + "required": [ + "catalogVersion", + "authority", + "hardOracles", + "invariants", + "fixtures", + ], + "properties": { + "catalogVersion": {"const": "1.0.0"}, + "authority": {"type": "object"}, + "hardOracles": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + { + "type": "object", + "additionalProperties": False, + "required": ["name", "requiredValue", "veto"], + "properties": { + "name": {"const": name}, + "requiredValue": {"const": 0}, + "veto": {"const": True}, + }, + } + for name in HARD_ORACLE_NAMES + ], + "items": False, + }, + "invariants": { + "type": "array", + "minItems": 15, + "maxItems": 15, + "uniqueItems": True, + "items": {"$ref": "#/$defs/invariant"}, + }, + "fixtures": { + "type": "array", + "minItems": 12, + "maxItems": 12, + "uniqueItems": True, + "items": {"$ref": "#/$defs/fixture"}, + }, + }, + "$defs": { + "invariant": { + "type": "object", + "additionalProperties": False, + "required": invariant_required, + "properties": { + **{field: {} for field in invariant_required}, + "id": {"enum": list(CANONICAL_INVARIANT_IDS)}, + "applicability": { + "type": "object", + "additionalProperties": False, + "required": ["mode", "applicableFrom", "rationale"], + "properties": { + "mode": {}, + "applicableFrom": {}, + "rationale": {}, + }, + }, + "expectedEvidence": { + "type": "object", + "additionalProperties": False, + "required": ["property", "postgres", "runtimeOrDelivery"], + "properties": { + "property": {}, + "postgres": {}, + "runtimeOrDelivery": {}, + }, + }, + }, + }, + "fixture": { + "type": "object", + "additionalProperties": False, + "required": fixture_required, + "properties": { + **{field: {} for field in fixture_required}, + "id": {"enum": [f"ACCEPT-{number:03d}" for number in range(1, 13)]}, + "carrier": { + "type": "object", + "additionalProperties": False, + "required": ["statusAtM0", "m0Expectation", "upgradeTrigger"], + "properties": { + "statusAtM0": {}, + "m0Expectation": {}, + "upgradeTrigger": {}, + }, + }, + "setup": { + "type": "object", + "additionalProperties": False, + "required": ["preconditions", "trustedIdentity"], + "properties": { + "preconditions": {}, + "trustedIdentity": {}, + }, + }, + "expected": { + "type": "object", + "additionalProperties": False, + "required": [ + "externalResponse", + "packageOrError", + "evidence", + "businessEffects", + "io", + ], + "properties": { + "externalResponse": {}, + "packageOrError": {}, + "evidence": { + "type": "object", + "additionalProperties": False, + "required": [ + "unauthorizedEvidenceCount", + "unauthorizedContentBytes", + "missingContextFallbackCount", + "outboundBytes", + ], + "properties": { + "unauthorizedEvidenceCount": {}, + "unauthorizedContentBytes": {}, + "missingContextFallbackCount": {}, + "outboundBytes": {}, + }, + }, + "businessEffects": { + "type": "object", + "additionalProperties": False, + "required": [ + "wrongOrganizationEffectCount", + "mutationEffectCount", + "totalEffectsAfterScenario", + ], + "properties": { + "wrongOrganizationEffectCount": {}, + "mutationEffectCount": {}, + "totalEffectsAfterScenario": {}, + }, + }, + "io": { + "type": "object", + "additionalProperties": False, + "required": [ + "providerCalls", + "indexCalls", + "modelCalls", + "actionCalls", + ], + "properties": { + "providerCalls": {}, + "indexCalls": {}, + "modelCalls": {}, + "actionCalls": {}, + }, + }, + }, + }, + }, + }, + }, + } + + +class ValidateSecurityCatalogTests(unittest.TestCase): + def assert_catalog_error( + self, + catalog: dict[str, object], + expected_message: str, + schema: dict[str, object] | None = None, + ) -> CatalogValidationError: + with self.assertRaises(CatalogValidationError) as raised: + validate_catalog(catalog, schema or make_schema()) + self.assertIn(expected_message, raised.exception.errors) + return raised.exception + + def test_valid_catalog_returns_counts_and_fixture_mapping_report(self) -> None: + report = validate_catalog(make_catalog(), make_schema()) + + self.assertEqual(report.invariant_count, 15) + self.assertEqual(report.fixture_count, 12) + self.assertEqual( + render_report(report).splitlines()[:4], + [ + "security catalog valid: 15 invariants, 12 fixtures", + "fixture -> invariants:", + " ACCEPT-001: TENANT-OWNERSHIP-001", + " ACCEPT-002: TENANT-FK-002", + ], + ) + + def test_catalog_version_is_frozen(self) -> None: + catalog = make_catalog() + schema = make_schema() + catalog["catalogVersion"] = "999.0.0" + object_at(schema, "properties", "catalogVersion")["const"] = "999.0.0" + + self.assert_catalog_error( + catalog, + "catalogVersion: must be the supported version '1.0.0'", + schema, + ) + + def test_hard_oracle_order_is_frozen(self) -> None: + catalog = make_catalog() + hard_oracles = object_list_at(catalog, "hardOracles") + hard_oracles[0], hard_oracles[1] = hard_oracles[1], hard_oracles[0] + + self.assert_catalog_error( + catalog, + "hardOracles: must use the canonical order: " + + ", ".join(HARD_ORACLE_NAMES), + ) + + def test_unknown_catalog_fields_are_rejected_at_every_boundary(self) -> None: + catalog = make_catalog() + catalog["unexpected"] = True + invariants = object_list_at(catalog, "invariants") + fixtures = object_list_at(catalog, "fixtures") + object_at(invariants[0], "applicability")["unexpected"] = True + object_at(fixtures[0], "expected", "io")["unexpected"] = 0 + + error = self.assert_catalog_error( + catalog, + "catalog.unexpected: is not allowed", + ) + self.assertIn( + "invariants[0].applicability.unexpected: is not allowed", + error.errors, + ) + self.assertIn( + "fixtures[0].expected.io.unexpected: is not allowed", + error.errors, + ) + + def test_schema_must_freeze_nested_shapes_and_canonical_ids(self) -> None: + schema = load_document(DEFAULT_SCHEMA_PATH) + object_at(schema, "$defs", "invariant")["additionalProperties"] = True + object_at(schema, "properties", "invariants")["type"] = "object" + object_at(schema, "$defs", "invariant", "properties")["id"] = {"type": "string"} + object_at(schema, "$defs", "parameterizedCase")["additionalProperties"] = True + object_at(schema, "$defs", "parameterizedCase")["required"] = ["id"] + + error = self.assert_catalog_error( + load_document(DEFAULT_CATALOG_PATH), + "schema.properties.invariants.type: must be 'array'", + schema, + ) + self.assertIn( + "schema.invariants.items.additionalProperties: must be false", + error.errors, + ) + self.assertIn( + "schema.$defs.parameterizedCase.additionalProperties: must be false", + error.errors, + ) + self.assertIn( + "schema.$defs.parameterizedCase.required: must declare 'mutation'", + error.errors, + ) + self.assertIn( + "schema.invariants.items.properties.id.enum: must freeze the " + "canonical ordered IDs", + error.errors, + ) + + def test_schema_hard_oracle_tuple_is_closed(self) -> None: + schema = make_schema() + hard_oracle_schema = object_at(schema, "properties", "hardOracles") + hard_oracle_schema.pop("items", None) + object_list_at(hard_oracle_schema, "prefixItems")[0].pop("required", None) + + error = self.assert_catalog_error( + make_catalog(), + "schema.properties.hardOracles.items: must be false", + schema, + ) + self.assertIn( + "schema.properties.hardOracles.prefixItems[0].required: must be an array", + error.errors, + ) + + def test_catalog_is_validated_against_schema_constraints(self) -> None: + catalog = make_catalog() + schema = make_schema() + object_at(schema, "properties", "catalogVersion")["pattern"] = "^never$" + + self.assert_catalog_error( + catalog, + "catalog.catalogVersion: must match schema pattern '^never$'", + schema, + ) + + def test_validate_files_rejects_missing_and_escaping_authority_documents( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + catalog = make_catalog() + object_at(catalog, "authority")["documentRefs"] = [ + "docs/security/missing.md" + ] + for invariant in object_list_at(catalog, "invariants"): + invariant["authorityRefs"] = ["docs/security/missing.md#oracle"] + for fixture in object_list_at(catalog, "fixtures"): + fixture["authorityRefs"] = ["#5", "docs/security/missing.md#oracle"] + catalog_path = root / "eval/catalogs/security-invariants.yaml" + schema_path = root / "eval/catalogs/security-catalog.schema.json" + catalog_path.parent.mkdir(parents=True) + catalog_path.write_text(json.dumps(catalog), encoding="utf-8") + schema_path.write_text(json.dumps(make_schema()), encoding="utf-8") + + with self.assertRaises(CatalogValidationError) as raised: + validate_files(catalog_path, schema_path, repository_root=root) + + self.assertIn( + "authority.documentRefs[0]: tracked document does not exist: " + "'docs/security/missing.md'", + raised.exception.errors, + ) + + object_at(catalog, "authority")["documentRefs"] = ["../outside.md"] + for invariant in object_list_at(catalog, "invariants"): + invariant["authorityRefs"] = ["../outside.md#oracle"] + for fixture in object_list_at(catalog, "fixtures"): + fixture["authorityRefs"] = ["#5", "../outside.md#oracle"] + catalog_path.write_text(json.dumps(catalog), encoding="utf-8") + + with self.assertRaises(CatalogValidationError) as escaping: + validate_files(catalog_path, schema_path, repository_root=root) + + self.assertIn( + "authority.documentRefs[0]: must be a repository-relative path " + "without '..'", + escaping.exception.errors, + ) + + def test_validate_files_rejects_a_nonexistent_markdown_heading_anchor(self) -> None: + repository_root = Path(__file__).resolve().parents[2] + catalog = make_catalog() + invariants = catalog["invariants"] + assert isinstance(invariants, list) + invariants[0]["authorityRefs"] = [ + "docs/security/context-engine-threat-model.md#not-a-real-heading" + ] + + with tempfile.TemporaryDirectory() as directory: + catalog_path = Path(directory, "security-invariants.yaml") + schema_path = Path(directory, "security-catalog.schema.json") + catalog_path.write_text(json.dumps(catalog), encoding="utf-8") + schema_path.write_text(json.dumps(make_schema()), encoding="utf-8") + + with self.assertRaises(CatalogValidationError) as raised: + validate_files( + catalog_path, + schema_path, + repository_root=repository_root, + ) + + self.assertIn( + ( + "invariants[0].authorityRefs[0]: Markdown heading anchor does " + "not exist: " + "'docs/security/context-engine-threat-model.md#not-a-real-heading'" + ), + raised.exception.errors, + ) + + def test_validate_files_rejects_untracked_and_ignored_authority_documents( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run( + ["git", "-C", str(root), "init", "--quiet"], + check=True, + capture_output=True, + text=True, + ) + docs = root / "docs" + docs.mkdir() + (docs / "untracked.md").write_text("# Real heading\n", encoding="utf-8") + (docs / "ignored.md").write_text("# Real heading\n", encoding="utf-8") + (root / ".gitignore").write_text("docs/ignored.md\n", encoding="utf-8") + + catalog = make_catalog() + object_at(catalog, "authority")["documentRefs"] = [ + "docs/untracked.md", + "docs/ignored.md", + ] + for invariant in object_list_at(catalog, "invariants"): + invariant["authorityRefs"] = ["docs/untracked.md#real-heading"] + for fixture in object_list_at(catalog, "fixtures"): + fixture["authorityRefs"] = ["#5", "docs/ignored.md#real-heading"] + + catalog_path = root / "security-invariants.yaml" + schema_path = root / "security-catalog.schema.json" + catalog_path.write_text(json.dumps(catalog), encoding="utf-8") + schema_path.write_text(json.dumps(make_schema()), encoding="utf-8") + + with self.assertRaises(CatalogValidationError) as raised: + validate_files(catalog_path, schema_path, repository_root=root) + + self.assertIn( + ( + "authority.documentRefs[0]: must reference a Git-tracked file: " + "'docs/untracked.md'" + ), + raised.exception.errors, + ) + self.assertIn( + ( + "authority.documentRefs[1]: must reference a Git-tracked file: " + "'docs/ignored.md'" + ), + raised.exception.errors, + ) + + def test_validate_files_accepts_unicode_and_duplicate_heading_anchors(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run( + ["git", "-C", str(root), "init", "--quiet"], + check=True, + capture_output=True, + text=True, + ) + docs = root / "docs" + docs.mkdir() + authority_document = docs / "authority.md" + authority_document.write_text( + "# 重复 标题\n\n# 重复 标题\n", + encoding="utf-8", + ) + subprocess.run( + ["git", "-C", str(root), "add", "--", "docs/authority.md"], + check=True, + capture_output=True, + text=True, + ) + + catalog = make_catalog() + object_at(catalog, "authority")["documentRefs"] = ["docs/authority.md"] + for invariant in object_list_at(catalog, "invariants"): + invariant["authorityRefs"] = ["docs/authority.md#重复-标题"] + for fixture in object_list_at(catalog, "fixtures"): + fixture["authorityRefs"] = ["#5", "docs/authority.md#重复-标题-1"] + + catalog_path = root / "security-invariants.yaml" + schema_path = root / "security-catalog.schema.json" + catalog_path.write_text(json.dumps(catalog), encoding="utf-8") + schema_path.write_text(json.dumps(make_schema()), encoding="utf-8") + + report = validate_files(catalog_path, schema_path, repository_root=root) + + self.assertEqual(report.invariant_count, 15) + self.assertEqual(report.fixture_count, 12) + + def test_cli_default_paths_are_anchored_to_repository(self) -> None: + repository_root = Path(__file__).resolve().parents[2] + if not (repository_root / "eval/catalogs/security-invariants.yaml").is_file(): + self.skipTest("tracked catalog is landing in the parallel TDD task") + + previous_cwd = Path.cwd() + stdout = io.StringIO() + stderr = io.StringIO() + try: + os.chdir(tempfile.gettempdir()) + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + exit_code = main([]) + finally: + os.chdir(previous_cwd) + + self.assertEqual(exit_code, 0, stderr.getvalue()) + self.assertIn( + "security catalog valid: 15 invariants, 12 fixtures", stdout.getvalue() + ) + + def test_tracked_catalog_and_schema_validate_together(self) -> None: + repository_root = Path(__file__).resolve().parents[2] + catalog_path = repository_root / "eval/catalogs/security-invariants.yaml" + schema_path = repository_root / "eval/catalogs/security-catalog.schema.json" + if not schema_path.is_file(): + self.skipTest("tracked schema is landing in the parallel TDD task") + + report = validate_files( + catalog_path, schema_path, repository_root=repository_root + ) + + self.assertEqual(report.invariant_count, 15) + self.assertEqual(report.fixture_count, 12) + + def test_tracked_catalog_freezes_later_carrier_and_source_acl_semantics( + self, + ) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + fixtures = { + fixture["id"]: fixture + for fixture in catalog["fixtures"] + if isinstance(fixture, dict) + } + + accept_005 = fixtures["ACCEPT-005"] + self.assertEqual(accept_005["carrier"]["statusAtM0"], "future") + self.assertEqual(accept_005["carrier"]["m0Expectation"], "fail_closed") + self.assertEqual( + accept_005["expected"]["io"], + { + "providerCalls": 0, + "indexCalls": 0, + "modelCalls": 0, + "actionCalls": 0, + }, + ) + self.assertEqual( + fixtures["ACCEPT-009"]["invariantRefs"], + ["INDEX-NOT-AUTHORITY-005", "REVOCATION-006"], + ) + + def test_tracked_catalog_matches_runtime_outcome_and_timing_authority( + self, + ) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + fixtures = { + fixture["id"]: fixture + for fixture in catalog["fixtures"] + if isinstance(fixture, dict) + } + + for fixture_id, (body_kind, result_kind) in RUNTIME_OUTCOMES.items(): + fixture = fixtures[fixture_id] + self.assertEqual(fixture["expected"]["externalResponse"]["status"], 200) + self.assertEqual( + fixture["expected"]["externalResponse"]["body"]["kind"], + body_kind, + ) + self.assertEqual(fixture["expected"]["packageOrError"]["kind"], result_kind) + + accept_011 = fixtures["ACCEPT-011"] + self.assertNotIn("sameTimingBucket", accept_011["expected"]["externalResponse"]) + self.assertNotIn("timingBucket", accept_011["operation"]["comparisonFields"]) + self.assertIs( + accept_011["expected"]["externalResponse"]["timingEqualityClaimed"], + False, + ) + for fixture_id in ("ACCEPT-001", "ACCEPT-011"): + fixture = fixtures[fixture_id] + response_body = fixture["expected"]["externalResponse"]["body"] + self.assertEqual( + response_body["package"]["coverage"], + {"status": "empty", "reason": "no_authorized_evidence"}, + ) + self.assertEqual(response_body["package"]["blocks"], []) + self.assertEqual(response_body["package"]["evidence"], []) + self.assertEqual(response_body["package"]["gaps"], []) + self.assertEqual( + response_body["package"]["budgetUsage"], + { + "tokens": 0, + "providerCalls": 0, + "costMicrounits": 0, + "elapsedMs": 0, + }, + ) + self.assertTrue(response_body["egressGrant"]) + self.assertEqual( + fixture["expected"]["packageOrError"]["coverageReason"], + "no_authorized_evidence", + ) + self.assertEqual( + fixture["expected"]["packageOrError"]["coverageStatus"], "empty" + ) + for fixture_id in ("ACCEPT-005", "ACCEPT-009"): + self.assertIs( + fixtures[fixture_id]["expected"]["externalResponse"]["body"][ + "retryable" + ], + False, + ) + + def test_tracked_catalog_matches_required_milestone_authority(self) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + for invariant in catalog["invariants"]: + invariant_id = invariant["id"] + expected = CANONICAL_REQUIRED_MILESTONES[invariant_id] + self.assertEqual(invariant["applicability"]["applicableFrom"], expected[0]) + self.assertEqual(invariant["requiredMilestones"], expected) + + def test_tracked_catalog_preserves_required_parameterized_security_cases( + self, + ) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + fixtures = { + fixture["id"]: fixture + for fixture in catalog["fixtures"] + if isinstance(fixture, dict) + } + expected_case_ids = { + "ACCEPT-007": [ + "BODY-INJECTION", + "DELIV-001", + "DELIV-002", + "DELIV-003", + "DELIV-004", + ], + "ACCEPT-008": [ + "LEASE-ORGANIZATION", + "LEASE-JOB", + "LEASE-OPERATION", + "LEASE-SOURCE", + "LEASE-RESOURCE", + "LEASE-REVISION", + "LEASE-SERVICE-ACTOR", + "LEASE-WORKLOAD", + "LEASE-POLICY-EPOCH", + "LEASE-AUDIENCE", + "LEASE-IDEMPOTENCY", + "LEASE-GENERATION", + "LEASE-ISSUED-AT", + "LEASE-EXPIRY", + "LEASE-NONCE", + "LEASE-REPLAY", + "LEASE-USER-IMPERSONATION", + ], + "ACCEPT-009": list(ACL_PROOF_CASE_IDS), + "ACCEPT-012": list(AUDIENCE_ACTION_CASE_IDS), + } + for fixture_id, expected_ids in expected_case_ids.items(): + cases = fixtures[fixture_id]["adversarialMutation"]["parameterizedCases"] + self.assertEqual([case["id"] for case in cases], expected_ids) + for case in cases: + if fixture_id == "ACCEPT-007" and case["id"] == "BODY-INJECTION": + expected_status, expected_outcome = 422, "invalid_request" + elif fixture_id == "ACCEPT-007": + expected_status, expected_outcome = 200, "request_not_available" + elif fixture_id == "ACCEPT-008": + expected_status, expected_outcome = 404, "work_not_available" + elif fixture_id == "ACCEPT-009": + expected_status, expected_outcome = 200, "request_not_available" + else: + expected_status, expected_outcome = 404, "action_not_available" + self.assertEqual(case["expectedStatus"], expected_status) + self.assertEqual(case["expectedOutcome"], expected_outcome) + self.assertEqual(case["expectedNewDurableEffects"], 0) + self.assertEqual(case["expectedWrongOrganizationEffects"], 0) + self.assertEqual(case["expectedContentWorkCalls"], 0) + if fixture_id in {"ACCEPT-009", "ACCEPT-012"}: + self.assertTrue(case["activatedOracle"]) + self.assertEqual( + fixtures[fixture_id]["expected"]["io"], + { + "providerCalls": 0, + "indexCalls": 0, + "modelCalls": 0, + "actionCalls": 0, + }, + ) + + accept_011 = fixtures["ACCEPT-011"] + self.assertEqual( + accept_011["adversarialMutation"]["probes"], + [ + "resource-cross-org", + "resource-same-org-denied", + "resource-missing", + ], + ) + self.assertEqual(accept_011["expected"]["packageOrError"]["packageCount"], 3) + + def test_parameterized_security_case_deletions_are_rejected(self) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + schema = load_document(DEFAULT_SCHEMA_PATH) + fixtures = object_list_at(catalog, "fixtures") + transport_cases = object_list_at( + fixtures[6], "adversarialMutation", "parameterizedCases" + ) + worker_cases = object_list_at( + fixtures[7], "adversarialMutation", "parameterizedCases" + ) + acl_cases = object_list_at( + fixtures[8], "adversarialMutation", "parameterizedCases" + ) + audience_action_cases = object_list_at( + fixtures[11], "adversarialMutation", "parameterizedCases" + ) + nonenumeration_mutation = object_at(fixtures[10], "adversarialMutation") + probes = nonenumeration_mutation["probes"] + assert isinstance(probes, list) + transport_cases.pop() + worker_cases.pop(4) + probes.pop(0) + transport_cases[0]["expectedContentWorkCalls"] = 1 + transport_cases[1]["expectedStatus"] = 503 + worker_cases[0]["expectedOutcome"] = "organization_mismatch" + acl_cases.pop(1) + audience_action_cases[-1]["activatedOracle"] = "" + + with self.assertRaises(CatalogValidationError) as raised: + validate_catalog(catalog, schema) + + self.assertIn( + "fixtures[6].adversarialMutation.parameterizedCases: ids must be the " + "canonical ordered set ['BODY-INJECTION', 'DELIV-001', 'DELIV-002', " + "'DELIV-003', 'DELIV-004']", + raised.exception.errors, + ) + self.assertIn( + "fixtures[6].adversarialMutation.parameterizedCases[1].expectedStatus: " + "must be 200 for DELIV-001", + raised.exception.errors, + ) + self.assertIn( + "fixtures[7].adversarialMutation.parameterizedCases[0].expectedOutcome: " + "must be 'work_not_available' for LEASE-ORGANIZATION", + raised.exception.errors, + ) + self.assertIn( + "fixtures[6].adversarialMutation.parameterizedCases[0]." + "expectedContentWorkCalls: must be the numeric constant 0", + raised.exception.errors, + ) + self.assertTrue( + any( + error.startswith( + "fixtures[7].adversarialMutation.parameterizedCases: ids " + "must be the canonical ordered set" + ) + for error in raised.exception.errors + ) + ) + self.assertTrue( + any( + error.startswith( + "fixtures[8].adversarialMutation.parameterizedCases: ids " + "must be the canonical ordered set" + ) + for error in raised.exception.errors + ) + ) + self.assertIn( + "fixtures[11].adversarialMutation.parameterizedCases[13]." + "activatedOracle: must be a non-empty string", + raised.exception.errors, + ) + self.assertIn( + "fixtures[10].adversarialMutation.probes: must be the canonical " + "ordered probe set ['resource-cross-org', " + "'resource-same-org-denied', 'resource-missing']", + raised.exception.errors, + ) + + def test_absorbed_evidence_and_case_outcomes_cannot_be_removed(self) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + schema = load_document(DEFAULT_SCHEMA_PATH) + invariants = { + invariant["id"]: invariant + for invariant in catalog["invariants"] + if isinstance(invariant, dict) + } + fixtures = object_list_at(catalog, "fixtures") + revocation_evidence = invariants["REVOCATION-006"]["expectedEvidence"][ + "runtimeOrDelivery" + ] + assert isinstance(revocation_evidence, list) + revocation_evidence.remove("PROV-019") + transport_evidence = invariants["TRANSPORT-UNTRUSTED-008"]["expectedEvidence"][ + "runtimeOrDelivery" + ] + assert isinstance(transport_evidence, list) + self.assertIn("DELIV-004", transport_evidence) + transport_evidence.remove("DELIV-004") + acl_cases = object_list_at( + fixtures[8], "adversarialMutation", "parameterizedCases" + ) + del acl_cases[0]["expectedStatus"] + acl_cases[1]["mutation"] = None + acl_cases[2]["activatedOracle"] = "pass" + + with self.assertRaises(CatalogValidationError) as raised: + validate_catalog(catalog, schema) + + self.assertIn( + "invariants[5].expectedEvidence.runtimeOrDelivery: must preserve " + "absorbed derived case 'PROV-019' for REVOCATION-006", + raised.exception.errors, + ) + self.assertIn( + "invariants[7].expectedEvidence.runtimeOrDelivery: must preserve " + "absorbed derived case 'DELIV-004' for TRANSPORT-UNTRUSTED-008", + raised.exception.errors, + ) + self.assertIn( + "fixtures[8].adversarialMutation.parameterizedCases[0]." + "expectedStatus: must be 200 for PROV-013", + raised.exception.errors, + ) + self.assertTrue( + any( + error.endswith(".expectedStatus: is required by the schema") + for error in raised.exception.errors + ) + ) + self.assertIn( + "fixtures[8].adversarialMutation.parameterizedCases[1].mutation: " + "must be a non-empty string or a non-negative integer", + raised.exception.errors, + ) + self.assertIn( + "fixtures[8].adversarialMutation.parameterizedCases[2]." + "activatedOracle: must preserve the canonical activated oracle " + "for PROV-015", + raised.exception.errors, + ) + + def test_runtime_outcome_and_milestone_drift_are_rejected(self) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + schema = load_document(DEFAULT_SCHEMA_PATH) + fixtures = object_list_at(catalog, "fixtures") + invariants = object_list_at(catalog, "invariants") + object_at(fixtures[0], "expected", "externalResponse")["status"] = 404 + object_at(invariants[5], "applicability")["applicableFrom"] = "M0" + invariants[5]["requiredMilestones"] = ["M0", "M1"] + + with self.assertRaises(CatalogValidationError) as raised: + validate_catalog(catalog, schema) + + self.assertIn( + "fixtures[0].expected.externalResponse.status: must be 200 for " + "the canonical Runtime outcome", + raised.exception.errors, + ) + + def test_non_runtime_fail_closed_outcome_drift_is_rejected(self) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + schema = load_document(DEFAULT_SCHEMA_PATH) + fixtures = { + fixture["id"]: fixture + for fixture in object_list_at(catalog, "fixtures") + } + object_at(fixtures["ACCEPT-007"], "expected")["externalResponse"] = { + "status": 200, + "code": "resolved", + } + object_at(fixtures["ACCEPT-008"], "expected")["packageOrError"] = { + "kind": "ContextPackage", + "newReceiptCreated": True, + } + object_at(fixtures["ACCEPT-012"], "expected")["externalResponse"] = { + "status": 200, + "body": {"kind": "resolved"}, + } + + with self.assertRaises(CatalogValidationError) as raised: + validate_catalog(catalog, schema) + + self.assertIn( + "fixtures[6].expected.externalResponse: must preserve the canonical " + "fail-closed outcome for ACCEPT-007", + raised.exception.errors, + ) + self.assertIn( + "fixtures[7].expected.packageOrError: must preserve the canonical " + "fail-closed outcome for ACCEPT-008", + raised.exception.errors, + ) + self.assertIn( + "fixtures[11].expected.externalResponse: must preserve the canonical " + "fail-closed outcome for ACCEPT-012", + raised.exception.errors, + ) + + def test_resolved_empty_outcome_shape_drift_is_rejected(self) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + schema = load_document(DEFAULT_SCHEMA_PATH) + fixtures = object_list_at(catalog, "fixtures") + body = object_at(fixtures[0], "expected", "externalResponse", "body") + package = object_at(body, "package") + coverage = object_at(package, "coverage") + body.pop("egressGrant") + coverage["reason"] = "source_unavailable" + package["gaps"] = [{"category": "capability_unsupported"}] + + with self.assertRaises(CatalogValidationError) as raised: + validate_catalog(catalog, schema) + + self.assertIn( + "fixtures[0].expected.externalResponse.body.egressGrant: must be a " + "non-empty string", + raised.exception.errors, + ) + self.assertIn( + "fixtures[0].expected.externalResponse.body.package.coverage.reason: " + "must be 'no_authorized_evidence' for a hidden or missing Acquire", + raised.exception.errors, + ) + self.assertIn( + "fixtures[0].expected.externalResponse.body.package.gaps: must be empty " + "because no_authorized_evidence is coverage, not a Provider gap", + raised.exception.errors, + ) + + def test_milestone_drift_is_rejected(self) -> None: + catalog = load_document(DEFAULT_CATALOG_PATH) + schema = load_document(DEFAULT_SCHEMA_PATH) + invariants = object_list_at(catalog, "invariants") + object_at(invariants[5], "applicability")["applicableFrom"] = "M0" + invariants[5]["requiredMilestones"] = ["M0", "M1"] + + with self.assertRaises(CatalogValidationError) as raised: + validate_catalog(catalog, schema) + + self.assertIn( + "invariants[5].applicability.applicableFrom: must be 'M1' for " + "REVOCATION-006", + raised.exception.errors, + ) + self.assertIn( + "invariants[5].requiredMilestones: must be the canonical sequence " + "['M1', 'M2'] for REVOCATION-006", + raised.exception.errors, + ) + + def test_cli_reads_json_compatible_yaml_and_prints_report(self) -> None: + with tempfile.TemporaryDirectory() as directory: + catalog_path = Path(directory, "security-invariants.yaml") + schema_path = Path(directory, "security-catalog.schema.json") + catalog_path.write_text(json.dumps(make_catalog()), encoding="utf-8") + schema_path.write_text(json.dumps(make_schema()), encoding="utf-8") + stdout = io.StringIO() + stderr = io.StringIO() + + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + exit_code = main([str(catalog_path), "--schema", str(schema_path)]) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr.getvalue(), "") + self.assertIn( + "security catalog valid: 15 invariants, 12 fixtures\n", stdout.getvalue() + ) + self.assertIn(" ACCEPT-012: TRACE-REDACTION-012\n", stdout.getvalue()) + + def test_duplicate_invariant_id_is_rejected(self) -> None: + catalog = make_catalog() + invariants = catalog["invariants"] + assert isinstance(invariants, list) + invariants[1]["id"] = invariants[0]["id"] + + self.assert_catalog_error( + catalog, + "invariants: duplicate id 'TENANT-OWNERSHIP-001'", + ) + + def test_regex_valid_but_noncanonical_invariant_id_is_rejected(self) -> None: + catalog = make_catalog() + invariants = catalog["invariants"] + assert isinstance(invariants, list) + invariants[12]["id"] = "CACHE-SCOPE-013" + + error = self.assert_catalog_error( + catalog, + ( + "invariants: ids must be the canonical ordered set: " + + ", ".join(CANONICAL_INVARIANT_IDS) + ), + ) + self.assertNotIn( + "invariants[12].id: must match ^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-[0-9]{3}$", + error.errors, + ) + + def test_unknown_fixture_invariant_reference_is_rejected(self) -> None: + catalog = make_catalog() + fixtures = catalog["fixtures"] + assert isinstance(fixtures, list) + fixtures[0]["invariantRefs"] = ["UNKNOWN-999"] + + self.assert_catalog_error( + catalog, + "fixtures[0].invariantRefs[0]: unknown invariant 'UNKNOWN-999'", + ) + + def test_duplicate_fixture_invariant_reference_is_rejected(self) -> None: + catalog = make_catalog() + fixtures = catalog["fixtures"] + assert isinstance(fixtures, list) + invariant_ref = fixtures[0]["invariantRefs"][0] + fixtures[0]["invariantRefs"] = [invariant_ref, invariant_ref] + + self.assert_catalog_error( + catalog, + "fixtures[0].invariantRefs: must contain unique strings", + ) + + def test_missing_fixture_field_is_rejected(self) -> None: + catalog = make_catalog() + fixtures = catalog["fixtures"] + assert isinstance(fixtures, list) + del fixtures[0]["operation"] + + error = self.assert_catalog_error( + catalog, + "fixtures[0].operation: is required", + ) + self.assertIn("fixtures[0].operation: must be an object", error.errors) + + def test_unavailable_carrier_must_fail_closed_with_zero_io(self) -> None: + catalog = make_catalog() + fixtures = catalog["fixtures"] + assert isinstance(fixtures, list) + fixture = fixtures[0] + fixture["decisionStatus"] = "future_case" + fixture["carrier"] = { + "statusAtM0": "unavailable", + "m0Expectation": "skipped", + "upgradeTrigger": "Activate the trusted delivery carrier.", + } + fixture["expected"]["io"]["modelCalls"] = 1 + + error = self.assert_catalog_error( + catalog, + ( + "fixtures[0].carrier.m0Expectation: must be 'fail_closed' " + "when statusAtM0 is 'unavailable'" + ), + ) + self.assertIn( + "fixtures[0].expected.io.modelCalls: must be 0 for an unavailable " + "or future carrier", + error.errors, + ) + + def test_unavailable_carrier_requires_an_upgrade_trigger(self) -> None: + catalog = make_catalog() + fixtures = catalog["fixtures"] + assert isinstance(fixtures, list) + fixtures[0]["decisionStatus"] = "future_case" + fixtures[0]["carrier"] = { + "statusAtM0": "future", + "m0Expectation": "fail_closed", + "upgradeTrigger": "", + } + + self.assert_catalog_error( + catalog, + "fixtures[0].carrier.upgradeTrigger: must be a non-empty string", + ) + + def test_skipped_or_deferred_fixture_status_is_rejected(self) -> None: + catalog = make_catalog() + fixtures = catalog["fixtures"] + assert isinstance(fixtures, list) + fixtures[0]["decisionStatus"] = "skipped" + + self.assert_catalog_error( + catalog, + ( + "fixtures[0].decisionStatus: must be accepted or future_case; " + "skipped and deferred are forbidden" + ), + ) + + def test_hard_veto_metrics_must_be_zero_for_every_fixture(self) -> None: + catalog = make_catalog() + fixtures = catalog["fixtures"] + assert isinstance(fixtures, list) + fixtures[0]["expected"]["evidence"]["unauthorizedEvidenceCount"] = 1 + fixtures[0]["expected"]["evidence"]["missingContextFallbackCount"] = 1 + fixtures[0]["expected"]["businessEffects"]["wrongOrganizationEffectCount"] = 1 + + error = self.assert_catalog_error( + catalog, + ( + "fixtures[0].expected.evidence.unauthorizedEvidenceCount: " + "must be 0 for every acceptance fixture" + ), + ) + self.assertIn( + ( + "fixtures[0].expected.evidence.missingContextFallbackCount: " + "must be 0 for every acceptance fixture" + ), + error.errors, + ) + self.assertIn( + ( + "fixtures[0].expected.businessEffects.wrongOrganizationEffectCount: " + "must be 0 for every acceptance fixture" + ), + error.errors, + ) + + def test_schema_count_drift_is_rejected(self) -> None: + schema = make_schema() + object_at(schema, "properties", "invariants")["maxItems"] = 16 + + self.assert_catalog_error( + make_catalog(), + "schema.properties.invariants.maxItems: must be 15", + schema, + ) + + def test_cli_reports_validation_errors_to_stderr(self) -> None: + catalog = make_catalog() + object_list_at(catalog, "hardOracles")[0]["veto"] = False + with tempfile.TemporaryDirectory() as directory: + catalog_path = Path(directory, "security-invariants.yaml") + schema_path = Path(directory, "security-catalog.schema.json") + catalog_path.write_text(json.dumps(catalog), encoding="utf-8") + schema_path.write_text(json.dumps(make_schema()), encoding="utf-8") + stdout = io.StringIO() + stderr = io.StringIO() + + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + exit_code = main([str(catalog_path), "--schema", str(schema_path)]) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout.getvalue(), "") + self.assertIn("security catalog invalid:\n", stderr.getvalue()) + self.assertIn("hardOracles[0].veto: must be true", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main()