@gate-forge/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/LICENSE +202 -0
  2. package/dist/baselines/adoption.d.ts +76 -0
  3. package/dist/baselines/adoption.d.ts.map +1 -0
  4. package/dist/baselines/adoption.js +163 -0
  5. package/dist/baselines/adoption.js.map +1 -0
  6. package/dist/baselines/index.d.ts +102 -0
  7. package/dist/baselines/index.d.ts.map +1 -0
  8. package/dist/baselines/index.js +186 -0
  9. package/dist/baselines/index.js.map +1 -0
  10. package/dist/canonical-json.d.ts +51 -0
  11. package/dist/canonical-json.d.ts.map +1 -0
  12. package/dist/canonical-json.js +112 -0
  13. package/dist/canonical-json.js.map +1 -0
  14. package/dist/classifier/bind.d.ts +64 -0
  15. package/dist/classifier/bind.d.ts.map +1 -0
  16. package/dist/classifier/bind.js +188 -0
  17. package/dist/classifier/bind.js.map +1 -0
  18. package/dist/classifier/classify.d.ts +154 -0
  19. package/dist/classifier/classify.d.ts.map +1 -0
  20. package/dist/classifier/classify.js +1207 -0
  21. package/dist/classifier/classify.js.map +1 -0
  22. package/dist/classifier/glob.d.ts +48 -0
  23. package/dist/classifier/glob.d.ts.map +1 -0
  24. package/dist/classifier/glob.js +104 -0
  25. package/dist/classifier/glob.js.map +1 -0
  26. package/dist/classifier/index.d.ts +10 -0
  27. package/dist/classifier/index.d.ts.map +1 -0
  28. package/dist/classifier/index.js +21 -0
  29. package/dist/classifier/index.js.map +1 -0
  30. package/dist/classifier/schema.d.ts +150 -0
  31. package/dist/classifier/schema.d.ts.map +1 -0
  32. package/dist/classifier/schema.js +139 -0
  33. package/dist/classifier/schema.js.map +1 -0
  34. package/dist/config/index.d.ts +259 -0
  35. package/dist/config/index.d.ts.map +1 -0
  36. package/dist/config/index.js +523 -0
  37. package/dist/config/index.js.map +1 -0
  38. package/dist/fingerprints.d.ts +56 -0
  39. package/dist/fingerprints.d.ts.map +1 -0
  40. package/dist/fingerprints.js +59 -0
  41. package/dist/fingerprints.js.map +1 -0
  42. package/dist/graph/build.d.ts +16 -0
  43. package/dist/graph/build.d.ts.map +1 -0
  44. package/dist/graph/build.js +423 -0
  45. package/dist/graph/build.js.map +1 -0
  46. package/dist/graph/index.d.ts +12 -0
  47. package/dist/graph/index.d.ts.map +1 -0
  48. package/dist/graph/index.js +10 -0
  49. package/dist/graph/index.js.map +1 -0
  50. package/dist/graph/schema.d.ts +448 -0
  51. package/dist/graph/schema.d.ts.map +1 -0
  52. package/dist/graph/schema.js +237 -0
  53. package/dist/graph/schema.js.map +1 -0
  54. package/dist/graph/symbols.d.ts +129 -0
  55. package/dist/graph/symbols.d.ts.map +1 -0
  56. package/dist/graph/symbols.js +177 -0
  57. package/dist/graph/symbols.js.map +1 -0
  58. package/dist/graph/util.d.ts +40 -0
  59. package/dist/graph/util.d.ts.map +1 -0
  60. package/dist/graph/util.js +41 -0
  61. package/dist/graph/util.js.map +1 -0
  62. package/dist/index.d.ts +683 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +563 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/mapping/resolve.d.ts +177 -0
  67. package/dist/mapping/resolve.d.ts.map +1 -0
  68. package/dist/mapping/resolve.js +523 -0
  69. package/dist/mapping/resolve.js.map +1 -0
  70. package/dist/policy/coverage.d.ts +79 -0
  71. package/dist/policy/coverage.d.ts.map +1 -0
  72. package/dist/policy/coverage.js +119 -0
  73. package/dist/policy/coverage.js.map +1 -0
  74. package/dist/policy/evaluate.d.ts +275 -0
  75. package/dist/policy/evaluate.d.ts.map +1 -0
  76. package/dist/policy/evaluate.js +382 -0
  77. package/dist/policy/evaluate.js.map +1 -0
  78. package/dist/policy/index.d.ts +15 -0
  79. package/dist/policy/index.d.ts.map +1 -0
  80. package/dist/policy/index.js +14 -0
  81. package/dist/policy/index.js.map +1 -0
  82. package/dist/policy/trusted.d.ts +59 -0
  83. package/dist/policy/trusted.d.ts.map +1 -0
  84. package/dist/policy/trusted.js +96 -0
  85. package/dist/policy/trusted.js.map +1 -0
  86. package/dist/provenance.d.ts +170 -0
  87. package/dist/provenance.d.ts.map +1 -0
  88. package/dist/provenance.js +306 -0
  89. package/dist/provenance.js.map +1 -0
  90. package/dist/receipt/index.d.ts +74 -0
  91. package/dist/receipt/index.d.ts.map +1 -0
  92. package/dist/receipt/index.js +154 -0
  93. package/dist/receipt/index.js.map +1 -0
  94. package/dist/report/index.d.ts +100 -0
  95. package/dist/report/index.d.ts.map +1 -0
  96. package/dist/report/index.js +355 -0
  97. package/dist/report/index.js.map +1 -0
  98. package/dist/schemas/adoption.d.ts +40 -0
  99. package/dist/schemas/adoption.d.ts.map +1 -0
  100. package/dist/schemas/adoption.js +87 -0
  101. package/dist/schemas/adoption.js.map +1 -0
  102. package/dist/schemas/baseline.d.ts +19 -0
  103. package/dist/schemas/baseline.d.ts.map +1 -0
  104. package/dist/schemas/baseline.js +45 -0
  105. package/dist/schemas/baseline.js.map +1 -0
  106. package/dist/schemas/claim.d.ts +32 -0
  107. package/dist/schemas/claim.d.ts.map +1 -0
  108. package/dist/schemas/claim.js +34 -0
  109. package/dist/schemas/claim.js.map +1 -0
  110. package/dist/schemas/classification-policy.d.ts +84 -0
  111. package/dist/schemas/classification-policy.d.ts.map +1 -0
  112. package/dist/schemas/classification-policy.js +130 -0
  113. package/dist/schemas/classification-policy.js.map +1 -0
  114. package/dist/schemas/classification-signal.d.ts +125 -0
  115. package/dist/schemas/classification-signal.d.ts.map +1 -0
  116. package/dist/schemas/classification-signal.js +126 -0
  117. package/dist/schemas/classification-signal.js.map +1 -0
  118. package/dist/schemas/classification.d.ts +120 -0
  119. package/dist/schemas/classification.d.ts.map +1 -0
  120. package/dist/schemas/classification.js +154 -0
  121. package/dist/schemas/classification.js.map +1 -0
  122. package/dist/schemas/common.d.ts +73 -0
  123. package/dist/schemas/common.d.ts.map +1 -0
  124. package/dist/schemas/common.js +60 -0
  125. package/dist/schemas/common.js.map +1 -0
  126. package/dist/schemas/coverage-policy.d.ts +89 -0
  127. package/dist/schemas/coverage-policy.d.ts.map +1 -0
  128. package/dist/schemas/coverage-policy.js +81 -0
  129. package/dist/schemas/coverage-policy.js.map +1 -0
  130. package/dist/schemas/evidence.d.ts +65 -0
  131. package/dist/schemas/evidence.d.ts.map +1 -0
  132. package/dist/schemas/evidence.js +64 -0
  133. package/dist/schemas/evidence.js.map +1 -0
  134. package/dist/schemas/execution-result.d.ts +214 -0
  135. package/dist/schemas/execution-result.d.ts.map +1 -0
  136. package/dist/schemas/execution-result.js +240 -0
  137. package/dist/schemas/execution-result.js.map +1 -0
  138. package/dist/schemas/gate-receipt.d.ts +86 -0
  139. package/dist/schemas/gate-receipt.d.ts.map +1 -0
  140. package/dist/schemas/gate-receipt.js +193 -0
  141. package/dist/schemas/gate-receipt.js.map +1 -0
  142. package/dist/schemas/index.d.ts +22 -0
  143. package/dist/schemas/index.d.ts.map +1 -0
  144. package/dist/schemas/index.js +22 -0
  145. package/dist/schemas/index.js.map +1 -0
  146. package/dist/schemas/obligation.d.ts +37 -0
  147. package/dist/schemas/obligation.d.ts.map +1 -0
  148. package/dist/schemas/obligation.js +46 -0
  149. package/dist/schemas/obligation.js.map +1 -0
  150. package/dist/schemas/plugin.d.ts +19 -0
  151. package/dist/schemas/plugin.d.ts.map +1 -0
  152. package/dist/schemas/plugin.js +20 -0
  153. package/dist/schemas/plugin.js.map +1 -0
  154. package/dist/schemas/policy.d.ts +73 -0
  155. package/dist/schemas/policy.d.ts.map +1 -0
  156. package/dist/schemas/policy.js +46 -0
  157. package/dist/schemas/policy.js.map +1 -0
  158. package/dist/schemas/resource.d.ts +26 -0
  159. package/dist/schemas/resource.d.ts.map +1 -0
  160. package/dist/schemas/resource.js +29 -0
  161. package/dist/schemas/resource.js.map +1 -0
  162. package/dist/schemas/run-manifest.d.ts +72 -0
  163. package/dist/schemas/run-manifest.d.ts.map +1 -0
  164. package/dist/schemas/run-manifest.js +125 -0
  165. package/dist/schemas/run-manifest.js.map +1 -0
  166. package/dist/schemas/runner-adapter.d.ts +169 -0
  167. package/dist/schemas/runner-adapter.d.ts.map +1 -0
  168. package/dist/schemas/runner-adapter.js +2 -0
  169. package/dist/schemas/runner-adapter.js.map +1 -0
  170. package/dist/schemas/test-catalog.d.ts +472 -0
  171. package/dist/schemas/test-catalog.d.ts.map +1 -0
  172. package/dist/schemas/test-catalog.js +311 -0
  173. package/dist/schemas/test-catalog.js.map +1 -0
  174. package/dist/schemas/test-map.d.ts +99 -0
  175. package/dist/schemas/test-map.d.ts.map +1 -0
  176. package/dist/schemas/test-map.js +103 -0
  177. package/dist/schemas/test-map.js.map +1 -0
  178. package/dist/schemas/verdict.d.ts +73 -0
  179. package/dist/schemas/verdict.d.ts.map +1 -0
  180. package/dist/schemas/verdict.js +92 -0
  181. package/dist/schemas/verdict.js.map +1 -0
  182. package/dist/schemas/waiver.d.ts +36 -0
  183. package/dist/schemas/waiver.d.ts.map +1 -0
  184. package/dist/schemas/waiver.js +42 -0
  185. package/dist/schemas/waiver.js.map +1 -0
  186. package/dist/supervision/index.d.ts +170 -0
  187. package/dist/supervision/index.d.ts.map +1 -0
  188. package/dist/supervision/index.js +354 -0
  189. package/dist/supervision/index.js.map +1 -0
  190. package/dist/testing/clock.d.ts +50 -0
  191. package/dist/testing/clock.d.ts.map +1 -0
  192. package/dist/testing/clock.js +75 -0
  193. package/dist/testing/clock.js.map +1 -0
  194. package/dist/testing/env.d.ts +66 -0
  195. package/dist/testing/env.d.ts.map +1 -0
  196. package/dist/testing/env.js +106 -0
  197. package/dist/testing/env.js.map +1 -0
  198. package/dist/testing/gate-runner.d.ts +141 -0
  199. package/dist/testing/gate-runner.d.ts.map +1 -0
  200. package/dist/testing/gate-runner.js +173 -0
  201. package/dist/testing/gate-runner.js.map +1 -0
  202. package/dist/testing/gf19.d.ts +106 -0
  203. package/dist/testing/gf19.d.ts.map +1 -0
  204. package/dist/testing/gf19.js +282 -0
  205. package/dist/testing/gf19.js.map +1 -0
  206. package/dist/testing/index.d.ts +20 -0
  207. package/dist/testing/index.d.ts.map +1 -0
  208. package/dist/testing/index.js +14 -0
  209. package/dist/testing/index.js.map +1 -0
  210. package/dist/testing/red-probe.d.ts +138 -0
  211. package/dist/testing/red-probe.d.ts.map +1 -0
  212. package/dist/testing/red-probe.js +251 -0
  213. package/dist/testing/red-probe.js.map +1 -0
  214. package/dist/testing/temp-repo.d.ts +126 -0
  215. package/dist/testing/temp-repo.d.ts.map +1 -0
  216. package/dist/testing/temp-repo.js +214 -0
  217. package/dist/testing/temp-repo.js.map +1 -0
  218. package/dist/verdict/cause.d.ts +99 -0
  219. package/dist/verdict/cause.d.ts.map +1 -0
  220. package/dist/verdict/cause.js +206 -0
  221. package/dist/verdict/cause.js.map +1 -0
  222. package/dist/verdict/evaluate.d.ts +135 -0
  223. package/dist/verdict/evaluate.d.ts.map +1 -0
  224. package/dist/verdict/evaluate.js +1513 -0
  225. package/dist/verdict/evaluate.js.map +1 -0
  226. package/dist/verdict/index.d.ts +21 -0
  227. package/dist/verdict/index.d.ts.map +1 -0
  228. package/dist/verdict/index.js +20 -0
  229. package/dist/verdict/index.js.map +1 -0
  230. package/dist/verdict/pack-verifiers.d.ts +97 -0
  231. package/dist/verdict/pack-verifiers.d.ts.map +1 -0
  232. package/dist/verdict/pack-verifiers.js +763 -0
  233. package/dist/verdict/pack-verifiers.js.map +1 -0
  234. package/dist/verdict/registry.d.ts +180 -0
  235. package/dist/verdict/registry.d.ts.map +1 -0
  236. package/dist/verdict/registry.js +67 -0
  237. package/dist/verdict/registry.js.map +1 -0
  238. package/dist/waivers/index.d.ts +103 -0
  239. package/dist/waivers/index.d.ts.map +1 -0
  240. package/dist/waivers/index.js +192 -0
  241. package/dist/waivers/index.js.map +1 -0
  242. package/package.json +40 -0
@@ -0,0 +1,1207 @@
1
+ /**
2
+ * The deterministic classification engine (ADR 0003 D2): pure function
3
+ * from (resources, signals, policy, adapters, scan findings) to one
4
+ * decision or typed blocks per resource — no clock, no network, no
5
+ * filesystem, no framework syntax. Uncertainty resolves toward MORE
6
+ * obligations: unknown exposure defaults `user-facing`, unknown
7
+ * lifecycle operations default enabled, `internal` requires a complete
8
+ * closed-world certificate, and delete semantics are never guessed.
9
+ *
10
+ * Determinism contract: identical inputs produce byte-identical output.
11
+ * Every array in the result is totally ordered; all rule applications
12
+ * are sorted-set operations; no weighted confidence exists anywhere.
13
+ *
14
+ * Monotonicity invariant (property-tested): adding a positive
15
+ * exposure/lifecycle signal never removes an obligation — it can only
16
+ * add obligations or turn a certificate into a contradiction (which
17
+ * itself blocks while the conservative decision stands).
18
+ */
19
+ import { sha256Canonical } from '../canonical-json.js';
20
+ import { compareLocations, compareStrings } from '../graph/util.js';
21
+ import { ClassificationSchema } from '../schemas/classification.js';
22
+ import { signalId } from '../schemas/classification-signal.js';
23
+ import { BLOCK_DIMENSIONS, } from './schema.js';
24
+ import { globMatch } from './glob.js';
25
+ import { HTTP_ENDPOINT_RESOURCE_KIND } from '../graph/schema.js';
26
+ /** Stable rule ids rendered in decision traces (ADR 0003 D2). */
27
+ export const RULES = {
28
+ exposurePositive: 'EXPOSURE_POSITIVE_SIGNAL',
29
+ exposureInternalCertificate: 'EXPOSURE_INTERNAL_CERTIFICATE',
30
+ exposureDefault: 'EXPOSURE_DEFAULT_USER_FACING',
31
+ exposureOperationalProbe: 'EXPOSURE_OPERATIONAL_PROBE',
32
+ lifecyclePositive: 'LIFECYCLE_POSITIVE_SIGNAL',
33
+ lifecycleDeclaredSupported: 'LIFECYCLE_DECLARED_SUPPORTED',
34
+ lifecycleClosedWorldDisabled: 'LIFECYCLE_CLOSED_WORLD_DISABLED',
35
+ lifecycleDefault: 'LIFECYCLE_DEFAULT_ENABLED',
36
+ deleteProvenHard: 'DELETE_SEMANTICS_PROVEN_HARD',
37
+ deleteProvenArchive: 'DELETE_SEMANTICS_PROVEN_ARCHIVE',
38
+ planeEvidence: 'PLANE_DETECTOR_EVIDENCE',
39
+ lifecycleEndpointHttp: 'LIFECYCLE_ENDPOINT_HTTP',
40
+ identityEvidence: 'IDENTITY_DETECTOR_EVIDENCE',
41
+ adapterNameMatch: 'ADAPTER_NAME_MATCH',
42
+ orgInternalRule: 'ORGANIZATION_INTERNAL_RULE',
43
+ };
44
+ /** The four lifecycle-gated operations, in canonical order. */
45
+ const OPERATIONS = ['create', 'read', 'update', 'delete'];
46
+ /** Location sorter (codepoint, then line, then col). */
47
+ /** Locations sorted deterministically and DEDUPLICATED: a location is a
48
+ * set member — a detector emitting the same signal twice must not change
49
+ * any detail text (and therefore no decision fingerprint). */
50
+ function sortLocations(locations) {
51
+ const seen = new Set();
52
+ const unique = [];
53
+ for (const location of locations) {
54
+ const key = `${location.file}\u0000${location.line}\u0000${location.col}`;
55
+ if (seen.has(key))
56
+ continue;
57
+ seen.add(key);
58
+ unique.push(location);
59
+ }
60
+ return unique.sort(compareLocations);
61
+ }
62
+ /** Blocks sorted by code, then resourceId/name, then detail. */
63
+ function sortBlocks(blocks) {
64
+ return [...blocks].sort((a, b) => compareStrings(a.code, b.code) ||
65
+ compareStrings(a.resourceId ?? '', b.resourceId ?? '') ||
66
+ compareStrings(a.name ?? '', b.name ?? '') ||
67
+ compareStrings(a.detail, b.detail));
68
+ }
69
+ /** Contradictions sorted by dimension, then detail. */
70
+ function sortContradictions(entries) {
71
+ return [...entries].sort((a, b) => compareStrings(a.dimension, b.dimension) || compareStrings(a.detail, b.detail));
72
+ }
73
+ function locationText(location) {
74
+ return `${location.file}:${String(location.line)}`;
75
+ }
76
+ /**
77
+ * Whether a signal addresses the resource: exact plane-qualified id,
78
+ * bare name, or symbol (matched against the resource's `classQname` or
79
+ * `symbol` attribute). A symbol-scoped signal binds only to its own
80
+ * class when the resource carries class identity — bare-name binding
81
+ * then applies only to class-less resources (bare `Table()` calls), so
82
+ * a same-named table in another module/plane never inherits another
83
+ * tree's declaration.
84
+ */
85
+ function signalMatchesResource(signal, resource) {
86
+ const target = signal.target;
87
+ if (target.resourceId !== undefined && resource.id !== null && target.resourceId === resource.id) {
88
+ return true;
89
+ }
90
+ // Symbol-scoped targets bind ONLY to their own class: a same-named
91
+ // table in another module/plane (the two-declarative-base consumer
92
+ // shape) carries a different classQname and must never inherit
93
+ // another tree's declaration (delete semantics, identity, …).
94
+ // Resources without class identity (bare `Table()` calls) keep the
95
+ // bare-name binding.
96
+ if (target.symbol !== undefined) {
97
+ const classQname = resource.attributes['classQname'];
98
+ const symbol = resource.attributes['symbol'];
99
+ if (typeof classQname === 'string' || typeof symbol === 'string') {
100
+ return classQname === target.symbol || symbol === target.symbol;
101
+ }
102
+ if (target.resourceName === undefined)
103
+ return false;
104
+ }
105
+ return target.resourceName !== undefined && target.resourceName === resource.name;
106
+ }
107
+ /** Narrows a signal assertion to a boolean, when it is one. */
108
+ function assertionBoolean(assertion) {
109
+ return typeof assertion === 'boolean' ? assertion : null;
110
+ }
111
+ /** Narrows a signal assertion to a non-empty string, when it is one. */
112
+ function assertionString(assertion) {
113
+ return typeof assertion === 'string' ? assertion : null;
114
+ }
115
+ /** Narrows a signal assertion to an ordered string list, when it is one. */
116
+ function assertionStringList(assertion) {
117
+ return Array.isArray(assertion) ? assertion : null;
118
+ }
119
+ /** Narrows a signal assertion to a record, when it is one. */
120
+ function assertionRecord(assertion) {
121
+ if (Array.isArray(assertion) || typeof assertion !== 'object')
122
+ return null;
123
+ return assertion;
124
+ }
125
+ /** Reads the `{category}` payload of an internality reachability signal. */
126
+ function internalityCategory(signal) {
127
+ const record = assertionRecord(signal.assertion);
128
+ if (record !== null) {
129
+ const category = record['category'];
130
+ return typeof category === 'string' && category.length > 0 ? category : null;
131
+ }
132
+ return assertionString(signal.assertion);
133
+ }
134
+ /** Whether the signal positively asserts external reachability. */
135
+ function isPositiveExposure(signal) {
136
+ return signal.basis === 'code-positive' && signal.assertion !== false;
137
+ }
138
+ /**
139
+ * The precise operational-endpoint class (plan §6 "Health/operations",
140
+ * ADR 0004 D5): an `http.endpoint` whose compiled capabilities are
141
+ * EXACTLY `health-operations` and that has NO linked business resource —
142
+ * the same class the operational plane rule resolves to `global`
143
+ * (`resolveEndpointPlanes`). The capability set comes from the
144
+ * deterministic compiler rule `HEALTH_PATH_NO_SCHEMA` (GET/HEAD, no
145
+ * request schema, no business link, and a root-hung infrastructure-probe
146
+ * path shape — segment-exact probe words at depth <= 2 or the bare root).
147
+ * Positive detector facts, never absence-as-internal. Any additional
148
+ * capability (a health-path route whose response model also corroborated
149
+ * crud-read, a command suffix, …) or any business link keeps the endpoint
150
+ * in the full user-facing lattice: fail closed.
151
+ */
152
+ function isOperationalEndpoint(resource) {
153
+ if (resource.kind !== HTTP_ENDPOINT_RESOURCE_KIND)
154
+ return false;
155
+ const linked = resource.attributes['linkedResourceName'];
156
+ if (typeof linked === 'string' && linked.length > 0)
157
+ return false;
158
+ const capabilities = resource.attributes['capabilities'];
159
+ return (Array.isArray(capabilities) &&
160
+ capabilities.length === 1 &&
161
+ capabilities[0] === 'health-operations');
162
+ }
163
+ /**
164
+ * Whether the complete-scan attestation holds (ADR 0003 D4): the policy
165
+ * declares scan roots, and no finding or unresolved entry falls inside
166
+ * the attested scope. One shared judgment per run — the scope is the
167
+ * union of the configured roots, so a hole anywhere in the attested
168
+ * range invalidates every closed-world proof in this run.
169
+ */
170
+ function completeScanHolds(policy, scan) {
171
+ if (policy.scanRoots.length === 0 ||
172
+ (scan.configuredDetectors !== undefined &&
173
+ scan.successfulDetectors !== undefined &&
174
+ scan.successfulDetectors !== scan.configuredDetectors)) {
175
+ return false;
176
+ }
177
+ const requested = scan.requestedPaths ?? [];
178
+ if (requested.length === 0) {
179
+ return false;
180
+ }
181
+ // Coverage rules (red-team round 3): per-detector, per-file — never a
182
+ // flattened union. Declaring NO rules means no scan is provably
183
+ // complete: closed-world proofs stay unavailable (fail closed).
184
+ const rules = policy.coverage ?? [];
185
+ if (rules.length === 0)
186
+ return false;
187
+ for (const rule of rules) {
188
+ const report = scan.coverage?.find((entry) => entry.detector === rule.detector);
189
+ // The rule's detector must be configured AND must have reported
190
+ // coverage; a missing report is a hole, not an empty pass.
191
+ if (report === undefined)
192
+ return false;
193
+ const seen = new Set(report.scannedPaths);
194
+ for (const path of requested) {
195
+ const applicable = rule.appliesTo.some((pattern) => globMatch(path, pattern));
196
+ if (applicable && !seen.has(path))
197
+ return false;
198
+ }
199
+ }
200
+ // Coverage gaps (red-team round 4): every requested file must be
201
+ // applicable to at least one rule. A file no rule covers is a hole in
202
+ // the proof scope — the policy is incomplete, not the scan complete.
203
+ for (const path of requested) {
204
+ const covered = rules.some((rule) => rule.appliesTo.some((pattern) => globMatch(path, pattern)));
205
+ if (!covered)
206
+ return false;
207
+ }
208
+ for (const finding of scan.findings) {
209
+ for (const location of finding.locations) {
210
+ if (pathInRoots(location.file, policy))
211
+ return false;
212
+ }
213
+ }
214
+ for (const entry of scan.unresolved) {
215
+ if (pathInRoots(entry.location.file, policy))
216
+ return false;
217
+ }
218
+ return true;
219
+ }
220
+ /**
221
+ * Whether EXPOSURE-negative coverage holds (red-team round 6): every
222
+ * requested file must be covered by an EXHAUSTIVE `exposure.*` capability
223
+ * rule whose detector reported the file. Reading a file with a
224
+ * non-exhaustive (regex) detector proves nothing about the ABSENCE of
225
+ * exposure — so without an explicitly declared exhaustive exposure
226
+ * authority, the internality certificate is unavailable for the scope.
227
+ */
228
+ function exposureCoverageHolds(policy, scan) {
229
+ const rules = (policy.coverage ?? []).filter((rule) => rule.capability.startsWith('exposure.') && rule.exhaustive === true);
230
+ if (rules.length === 0)
231
+ return false;
232
+ const requested = scan.requestedPaths ?? [];
233
+ for (const path of requested) {
234
+ const granting = rules.filter((rule) => rule.appliesTo.some((pattern) => globMatch(path, pattern)));
235
+ if (granting.length === 0)
236
+ return false;
237
+ const covered = granting.some((rule) => scan.coverage
238
+ ?.filter((report) => report.detector === rule.detector)
239
+ .some((report) => report.scannedPaths.includes(path)));
240
+ if (!covered)
241
+ return false;
242
+ }
243
+ return true;
244
+ }
245
+ /** Whether a repo-root-relative path falls inside ANY configured scan root. */
246
+ function pathInRoots(path, policy) {
247
+ for (const root of policy.scanRoots) {
248
+ if (globMatch(path, root))
249
+ return true;
250
+ }
251
+ return false;
252
+ }
253
+ /** Fingerprint input: everything the decision deterministically depends on. */
254
+ function decisionFingerprint(input) {
255
+ return sha256Canonical({
256
+ target: input.resourceId ?? input.name,
257
+ kind: input.kind,
258
+ classification: input.classification,
259
+ rules: [...input.rules].sort(compareStrings),
260
+ defaultsApplied: [...input.defaultsApplied].sort(compareStrings),
261
+ signalIds: [...input.signalIds].sort(compareStrings),
262
+ detectors: [...input.detectors].sort(compareStrings),
263
+ contradictions: input.contradictions,
264
+ });
265
+ }
266
+ /**
267
+ * Validates a DETECTOR-channel reachability signal (internality,
268
+ * code-positive) against the trusted entry-point registry and the scan's
269
+ * coverage knowledge. Returns a single-cause rejection reason, or null
270
+ * when the signal is admissible for certificate use.
271
+ */
272
+ function reachabilityRejection(signal, trustedEntries, scan) {
273
+ const category = internalityCategory(signal);
274
+ if (category === null)
275
+ return 'the signal asserts no entry-point category';
276
+ const entry = trustedEntries.find((candidate) => candidate.category === category);
277
+ if (entry === undefined) {
278
+ return `category '${category}' is not a trusted internal entry point`;
279
+ }
280
+ if (entry.detector === undefined) {
281
+ return (`category '${category}' declares no trusted detector; reachability must be ` +
282
+ 'asserted by a bundled detector bound in classification-policy');
283
+ }
284
+ if (signal.detector.id !== entry.detector) {
285
+ return (`category '${category}' trusts detector '${entry.detector}' but the signal claims ` +
286
+ `'${signal.detector.id}'`);
287
+ }
288
+ const report = scan.coverage?.find((candidate) => candidate.detector === entry.detector);
289
+ if (report === undefined) {
290
+ return `trusted detector '${entry.detector}' reported no coverage`;
291
+ }
292
+ if (!report.scannedPaths.includes(signal.location.file)) {
293
+ return (`the signal's location '${signal.location.file}' is not in trusted detector ` +
294
+ `'${entry.detector}' reported coverage`);
295
+ }
296
+ if (scan.requestedPaths !== undefined && scan.requestedPaths.length > 0) {
297
+ if (!scan.requestedPaths.includes(signal.location.file)) {
298
+ return `the signal's location '${signal.location.file}' was never requested for scanning`;
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+ /**
304
+ * Whether a signal's dimension/basis combination is SUPPRESSIVE — it can
305
+ * only ever remove obligations or disable operations. Such signals are
306
+ * authoritative exclusively through the host-owned `authority` channel:
307
+ * on the detector channel they are rejected by CHANNEL, whatever
308
+ * detector identity they claim.
309
+ */
310
+ function isSuppressiveShape(signal) {
311
+ if (signal.dimension === 'internality') {
312
+ return signal.basis === 'declaration' || signal.basis === 'organization-policy';
313
+ }
314
+ return ((signal.dimension === 'lifecycle.create' ||
315
+ signal.dimension === 'lifecycle.read' ||
316
+ signal.dimension === 'lifecycle.update' ||
317
+ signal.dimension === 'lifecycle.delete') &&
318
+ signal.basis === 'code-negative-closed-world');
319
+ }
320
+ /**
321
+ * Whether an AUTHORITY-channel declaration names a configured source
322
+ * (a `declarations` value, or an explicit `gateforge.declaration:<key>`
323
+ * reference to a configured key). The channel carries the authority; the
324
+ * configured source keeps the organization in control of the syntax.
325
+ */
326
+ function isConfiguredDeclarationSource(signal, policy) {
327
+ if (Object.values(policy.declarations).includes(signal.source))
328
+ return true;
329
+ if (signal.source.startsWith('gateforge.declaration:')) {
330
+ const key = signal.source.slice('gateforge.declaration:'.length);
331
+ return Object.prototype.hasOwnProperty.call(policy.declarations, key);
332
+ }
333
+ return false;
334
+ }
335
+ /**
336
+ * Classifies every resource through the deterministic lattice (ADR 0003 D2).
337
+ *
338
+ * Args:
339
+ * input: resources, detector signals, host-issued authority signals,
340
+ * policy, adapters, and scan knowledge.
341
+ *
342
+ * Returns:
343
+ * ClassificationResult: one decision (or typed blocks) per resource,
344
+ * plus document-level stale/invalid/unauthorized-signal blocks — all
345
+ * sorted.
346
+ */
347
+ export function classifyResources(input) {
348
+ const staleTargets = [];
349
+ const invalidSignals = [];
350
+ const unauthorizedSuppressive = [];
351
+ const pluginSignals = [...input.signals].sort((a, b) => compareStrings(signalId(a), signalId(b)));
352
+ const authoritySignals = [...(input.authority ?? [])].sort((a, b) => compareStrings(signalId(a), signalId(b)));
353
+ const trustedEntries = input.policy.trustedInternalEntryPoints;
354
+ // Document-level signal hygiene first: stale targets, dimension/
355
+ // assertion shape mismatches, and CHANNEL violations are typed blocks,
356
+ // never silent drops.
357
+ const byResource = input.resources.map(() => []);
358
+ const authorityByResource = input.resources.map(() => []);
359
+ const route = (signal, channel, matched, index) => {
360
+ if (!matched) {
361
+ staleTargets.push({
362
+ code: 'STALE_SIGNAL_TARGET',
363
+ resourceId: signal.target.resourceId ?? null,
364
+ name: signal.target.resourceName ?? null,
365
+ detail: `signal from ${signal.source} (${signal.dimension}) targets ` +
366
+ `${describeTarget(signal)} which matches no discovered resource; ` +
367
+ 'the resource was removed, renamed, or the signal predates it',
368
+ locations: [signal.location],
369
+ });
370
+ return;
371
+ }
372
+ const shapeError = assertionShapeError(signal);
373
+ if (shapeError !== null) {
374
+ invalidSignals.push({
375
+ code: 'INVALID_SIGNAL',
376
+ resourceId: null,
377
+ name: null,
378
+ detail: `signal from ${signal.source} (${signal.dimension}): ${shapeError}`,
379
+ locations: [signal.location],
380
+ });
381
+ return;
382
+ }
383
+ for (const i of index)
384
+ (channel === 'authority' ? authorityByResource : byResource)[i]?.push(signal);
385
+ };
386
+ for (const signal of pluginSignals) {
387
+ if (signal.dimension === 'internality' && signal.basis === 'code-positive') {
388
+ // Reachability is LOAD-BEARING for the internality certificate —
389
+ // suppressive in effect (red-team round 5). Accept it only from the
390
+ // category's declared trusted detector, only at a location that
391
+ // detector's own coverage report includes and that was requested.
392
+ const rejection = reachabilityRejection(signal, trustedEntries, input.scan);
393
+ if (rejection !== null) {
394
+ unauthorizedSuppressive.push({
395
+ code: 'UNAUTHORIZED_SUPPRESSIVE_SIGNAL',
396
+ resourceId: null,
397
+ name: null,
398
+ detail: `reachability signal from ${signal.source} (${signal.detector.id}@` +
399
+ `${signal.detector.version}) asserting category '${internalityCategory(signal) ?? '<unknown>'}' ` +
400
+ `was rejected: ${rejection}; the certificate can only use reachability ` +
401
+ 'asserted by the category\'s declared trusted detector over files it actually examined',
402
+ locations: [signal.location],
403
+ });
404
+ continue;
405
+ }
406
+ }
407
+ if (isSuppressiveShape(signal)) {
408
+ // Channel violation: the detector channel can never carry
409
+ // suppressive intent. Fail LOUD — the attempt blocks the gate.
410
+ unauthorizedSuppressive.push({
411
+ code: 'UNAUTHORIZED_SUPPRESSIVE_SIGNAL',
412
+ resourceId: null,
413
+ name: null,
414
+ detail: `signal from ${signal.source} (${signal.dimension}, ${signal.basis}) arrived on the ` +
415
+ 'detector channel claiming ' +
416
+ `${signal.detector.id}@${signal.detector.version}; suppressive authority ` +
417
+ '(internality declarations, closed-world lifecycle proofs) is host-issued only — ' +
418
+ 'mint it through the engine (source declarations / policy), never via plugin output',
419
+ locations: [signal.location],
420
+ });
421
+ continue;
422
+ }
423
+ const matched = [];
424
+ for (let i = 0; i < input.resources.length; i++) {
425
+ const resource = input.resources[i];
426
+ if (resource === undefined)
427
+ continue;
428
+ if (signalMatchesResource(signal, resource))
429
+ matched.push(i);
430
+ }
431
+ route(signal, 'detector', matched.length > 0, matched);
432
+ }
433
+ for (const signal of authoritySignals) {
434
+ const matched = [];
435
+ for (let i = 0; i < input.resources.length; i++) {
436
+ const resource = input.resources[i];
437
+ if (resource === undefined)
438
+ continue;
439
+ if (signalMatchesResource(signal, resource))
440
+ matched.push(i);
441
+ }
442
+ route(signal, 'authority', matched.length > 0, matched);
443
+ }
444
+ const scanComplete = completeScanHolds(input.policy, input.scan);
445
+ const exposureComplete = exposureCoverageHolds(input.policy, input.scan);
446
+ const trustedCategories = new Set(input.policy.trustedInternalEntryPoints.map((entry) => entry.category));
447
+ const ctx = {
448
+ policy: input.policy,
449
+ adapters: input.adapters,
450
+ scanComplete,
451
+ exposureComplete,
452
+ trustedCategories,
453
+ trustedEntries,
454
+ };
455
+ const pools = input.resources.map((_, index) => ({
456
+ plugin: byResource[index] ?? [],
457
+ authority: authorityByResource[index] ?? [],
458
+ }));
459
+ const decisions = input.resources.map((resource, index) => classifyOne(resource, pools[index] ?? { plugin: [], authority: [] }, ctx));
460
+ resolveEndpointPlanes(input.resources, decisions, pools, ctx);
461
+ return {
462
+ schemaVersion: 1,
463
+ decisions: sortDecisions(decisions),
464
+ staleTargets: sortBlocks(staleTargets),
465
+ invalidSignals: sortBlocks(invalidSignals),
466
+ unauthorizedSuppressive: sortBlocks(unauthorizedSuppressive),
467
+ };
468
+ }
469
+ /**
470
+ * Endpoint plane inheritance (ADR 0004 D5): an `http.endpoint` resource
471
+ * whose plane stayed unresolved inherits the plane of exactly one linked
472
+ * business resource with a resolved plane; operational endpoints
473
+ * (capability `health-operations`, no business link) resolve to
474
+ * `global`. Derivation is engine-issued (authority channel) and runs as
475
+ * ONE deterministic pass — never chained endpoint-to-endpoint, never
476
+ * guessed when the link is ambiguous.
477
+ */
478
+ function resolveEndpointPlanes(resources, decisions, pools, ctx) {
479
+ const decisionsByName = new Map();
480
+ for (const decision of decisions) {
481
+ const list = decisionsByName.get(decision.name);
482
+ if (list !== undefined)
483
+ list.push(decision);
484
+ else
485
+ decisionsByName.set(decision.name, [decision]);
486
+ }
487
+ for (let index = 0; index < decisions.length; index += 1) {
488
+ const resource = resources[index];
489
+ const decision = decisions[index];
490
+ if (resource === undefined || decision === undefined)
491
+ continue;
492
+ if (resource.kind !== HTTP_ENDPOINT_RESOURCE_KIND)
493
+ continue;
494
+ if (decision.classification !== null)
495
+ continue;
496
+ if (!decision.blocks.some((block) => block.code === 'PLANE_UNRESOLVED'))
497
+ continue;
498
+ let plane = null;
499
+ let derivedFrom = null;
500
+ const linkedName = resource.attributes['linkedResourceName'];
501
+ if (typeof linkedName === 'string' && linkedName.length > 0) {
502
+ const linkedDecisions = decisionsByName.get(linkedName) ?? [];
503
+ const resolvedPlanes = new Set();
504
+ for (const linked of linkedDecisions) {
505
+ // Never chain through another endpoint; only business decisions count.
506
+ if (linked.kind === HTTP_ENDPOINT_RESOURCE_KIND)
507
+ continue;
508
+ if (linked.classification !== null)
509
+ resolvedPlanes.add(linked.classification.plane);
510
+ }
511
+ if (resolvedPlanes.size === 1) {
512
+ const only = [...resolvedPlanes][0];
513
+ if (only !== undefined) {
514
+ plane = only;
515
+ derivedFrom = 'linked-resource';
516
+ }
517
+ }
518
+ }
519
+ if (plane === null) {
520
+ const capabilities = resource.attributes['capabilities'];
521
+ const healthOperational = Array.isArray(capabilities) &&
522
+ capabilities.includes('health-operations') &&
523
+ typeof linkedName !== 'string';
524
+ if (healthOperational) {
525
+ plane = 'global';
526
+ derivedFrom = 'operational';
527
+ }
528
+ }
529
+ if (plane === null || derivedFrom === null)
530
+ continue;
531
+ const derived = {
532
+ schemaVersion: 1,
533
+ target: { resourceName: resource.name },
534
+ dimension: 'plane',
535
+ assertion: plane,
536
+ basis: 'declaration',
537
+ source: `gateforge.endpoint-compiler:${derivedFrom}`,
538
+ location: resource.location,
539
+ detector: { id: 'gateforge.endpoint-compiler', version: '1' },
540
+ };
541
+ const pool = pools[index];
542
+ decisions[index] = classifyOne(resource, {
543
+ plugin: pool?.plugin ?? [],
544
+ authority: [...(pool?.authority ?? []), derived],
545
+ }, ctx);
546
+ }
547
+ }
548
+ /** Renders a signal target for diagnostics. */
549
+ function describeTarget(signal) {
550
+ const target = signal.target;
551
+ if (target.resourceId !== undefined)
552
+ return `id '${target.resourceId}'`;
553
+ if (target.resourceName !== undefined)
554
+ return `name '${target.resourceName}'`;
555
+ if (target.symbol !== undefined)
556
+ return `symbol '${target.symbol}'`;
557
+ return '<unaddressed>';
558
+ }
559
+ /**
560
+ * Cross-field assertion validation (the zod schema is per-field): each
561
+ * dimension accepts only the assertion shapes its lattice rules read.
562
+ * Returns a single-cause error string, or null when the signal is
563
+ * well-formed for its dimension.
564
+ */
565
+ function assertionShapeError(signal) {
566
+ const assertion = signal.assertion;
567
+ switch (signal.dimension) {
568
+ case 'exposure':
569
+ case 'lifecycle.create':
570
+ case 'lifecycle.read':
571
+ case 'lifecycle.update':
572
+ case 'lifecycle.delete':
573
+ if (typeof assertion !== 'boolean' && typeof assertion !== 'string') {
574
+ return 'assertion must be a boolean or a channel string';
575
+ }
576
+ return null;
577
+ case 'plane':
578
+ if (!(assertion === 'tenant' || assertion === 'master' || assertion === 'global')) {
579
+ return "assertion must be one of: 'tenant', 'master', 'global'";
580
+ }
581
+ return null;
582
+ case 'identity':
583
+ if (!Array.isArray(assertion)) {
584
+ return 'assertion must be the ordered primary-key column list';
585
+ }
586
+ return null;
587
+ case 'delete-semantics':
588
+ if (!(assertion === 'hard' || assertion === 'archive')) {
589
+ return "assertion must be 'hard' or 'archive'";
590
+ }
591
+ return null;
592
+ case 'archive-state':
593
+ if (Array.isArray(assertion) || typeof assertion !== 'object') {
594
+ return 'assertion must be the owner-owned archived field-value record';
595
+ }
596
+ return null;
597
+ case 'internality':
598
+ case 'adapter-binding':
599
+ return null; // free-form: boolean intent, category string/record, adapter name
600
+ default:
601
+ return null;
602
+ }
603
+ }
604
+ /** Sorts decisions like graph resources: id-bearing first, then by name. */
605
+ function sortDecisions(decisions) {
606
+ return [...decisions].sort((a, b) => {
607
+ if (a.resourceId !== null && b.resourceId !== null) {
608
+ const byId = compareStrings(a.resourceId, b.resourceId);
609
+ if (byId !== 0)
610
+ return byId;
611
+ }
612
+ if (a.resourceId !== null)
613
+ return -1;
614
+ if (b.resourceId !== null)
615
+ return 1;
616
+ return compareStrings(a.name, b.name);
617
+ });
618
+ }
619
+ /**
620
+ * Runs the lattice for one resource. See ADR 0003 D2 for the normative
621
+ * rule order; every failure mode lands in `blocks` with a single-cause
622
+ * detail — nothing is guessed and nothing silently disappears.
623
+ *
624
+ * The channel pool separates DETECTOR-origin signals (structurally
625
+ * non-suppressive; document-level hygiene already rejected violations)
626
+ * from HOST-ISSUED authority signals (the only source of internality
627
+ * declarations and closed-world lifecycle proofs).
628
+ */
629
+ function classifyOne(resource, pool, ctx) {
630
+ // Non-suppressive reads (exposure, plane, identity, semantics,
631
+ // reachability, additive declarations) see both channels merged;
632
+ // suppressive reads use `pool.authority` exclusively.
633
+ const signals = [...pool.plugin, ...pool.authority];
634
+ const blocks = [];
635
+ const contradictions = [];
636
+ const rules = [];
637
+ const defaultsApplied = [];
638
+ const contributing = [];
639
+ const contribute = (...signalList) => {
640
+ contributing.push(...signalList);
641
+ };
642
+ // -- Plane ----------------------------------------------------------------
643
+ const planeCandidates = new Set();
644
+ const attributePlane = resource.attributes['plane'];
645
+ if (attributePlane === 'tenant' || attributePlane === 'master' || attributePlane === 'global') {
646
+ planeCandidates.add(attributePlane);
647
+ }
648
+ const planeSignals = signals.filter((s) => s.dimension === 'plane' && typeof s.assertion === 'string');
649
+ for (const signal of planeSignals)
650
+ planeCandidates.add(signal.assertion);
651
+ let plane;
652
+ if (planeCandidates.size === 1) {
653
+ const value = [...planeCandidates][0];
654
+ if (value === undefined) {
655
+ return blocked(resource, [planeUnresolvedBlock(resource, 'no plane evidence')]);
656
+ }
657
+ plane = value;
658
+ rules.push(RULES.planeEvidence);
659
+ contribute(...planeSignals);
660
+ }
661
+ else if (planeCandidates.size === 0) {
662
+ return blocked(resource, [
663
+ planeUnresolvedBlock(resource, 'no plane evidence (no plane signal and no detector plane attribute); ' +
664
+ 'identity normalization never guesses across tenant/master/global'),
665
+ ]);
666
+ }
667
+ else {
668
+ return blocked(resource, [
669
+ {
670
+ code: 'PLANE_CONTRADICTION',
671
+ resourceId: resource.id,
672
+ name: resource.name,
673
+ detail: `resource '${resource.name}' has ${planeCandidates.size} conflicting plane assertions ` +
674
+ `(${[...planeCandidates].sort(compareStrings).join(', ')}); ` +
675
+ 'add or remove plane evidence until one plane remains',
676
+ locations: sortLocations(planeSignals.map((s) => s.location)),
677
+ },
678
+ ]);
679
+ }
680
+ // -- Identity (ordered primary key) ---------------------------------------
681
+ const identitySignals = signals.filter((s) => s.dimension === 'identity' && Array.isArray(s.assertion));
682
+ const identityKeys = new Set();
683
+ for (const signal of identitySignals) {
684
+ identityKeys.add(joinKeys(signal.assertion));
685
+ }
686
+ let primaryKey;
687
+ if (identityKeys.size === 1) {
688
+ const only = [...identityKeys][0];
689
+ primaryKey = splitKeys(only);
690
+ rules.push(RULES.identityEvidence);
691
+ contribute(...identitySignals);
692
+ }
693
+ else if (identityKeys.size === 0) {
694
+ return blocked(resource, [
695
+ identityUnresolvedBlock(resource, 'no primary-key evidence (no identity signal); the key is never defaulted to `id`'),
696
+ ]);
697
+ }
698
+ else {
699
+ const distinct = [...identityKeys].map(splitKeys);
700
+ return blocked(resource, [
701
+ {
702
+ code: 'IDENTITY_CONTRADICTION',
703
+ resourceId: resource.id,
704
+ name: resource.name,
705
+ detail: `resource '${resource.name}' has ${distinct.length} conflicting primary-key assertions ` +
706
+ `(${distinct.map((keys) => keys.join('+')).sort(compareStrings).join(' vs ')}); ` +
707
+ 'the ordered key is never guessed',
708
+ locations: sortLocations(identitySignals.map((s) => s.location)),
709
+ },
710
+ ]);
711
+ }
712
+ // -- Exposure --------------------------------------------------------------
713
+ const positiveExposure = signals.filter((s) => s.dimension === 'exposure' && isPositiveExposure(s));
714
+ const positiveLifecycle = signals.filter((s) => (s.dimension === 'lifecycle.create' ||
715
+ s.dimension === 'lifecycle.read' ||
716
+ s.dimension === 'lifecycle.update' ||
717
+ s.dimension === 'lifecycle.delete') &&
718
+ s.basis === 'code-positive' &&
719
+ assertionBoolean(s.assertion) === true);
720
+ const internalDeclarations = pool.authority.filter((s) => s.dimension === 'internality' &&
721
+ (s.basis === 'declaration' || s.basis === 'organization-policy') &&
722
+ isConfiguredDeclarationSource(s, ctx.policy) &&
723
+ assertionBoolean(s.assertion) !== false);
724
+ const matchedInternalRules = ctx.policy.internalRules.filter((rule) => (rule.match.resourceName === undefined || globMatch(resource.name, rule.match.resourceName)) &&
725
+ (rule.match.resourceKind === undefined || rule.match.resourceKind === resource.kind));
726
+ const hasInternalIntent = internalDeclarations.length > 0 || matchedInternalRules.length > 0;
727
+ let exposure;
728
+ if (isOperationalEndpoint(resource)) {
729
+ // Operational probes (plan §6 "Health/operations") resolve through an
730
+ // engine-issued exposure lane, NOT the user-facing default and NOT an
731
+ // internality certificate: the class itself is positive compiler
732
+ // evidence (HEALTH_PATH_NO_SCHEMA, no business resource link). The
733
+ // architecture assigns these endpoints dependency-ready/degraded
734
+ // behavior evidence and explicitly "no artificial browser UI
735
+ // obligation" — so they must never demand a reviewed UI evidence
736
+ // adapter (ADAPTER_MISSING) they can never honestly carry.
737
+ // `internal` is the obligation posture that encodes exactly that (no
738
+ // CRUD/UI evidence lane, adapter optional), and the rule id below
739
+ // records WHY in the decision trace so the resolution is explainable
740
+ // rather than a silent internality claim. The global "unknown
741
+ // exposure defaults user-facing" default is untouched: every other
742
+ // resource without exposure facts still defaults user-facing.
743
+ exposure = 'internal';
744
+ rules.push(RULES.exposureOperationalProbe);
745
+ contribute(...positiveExposure);
746
+ }
747
+ else if (positiveExposure.length > 0) {
748
+ contribute(...positiveExposure);
749
+ exposure = 'user-facing';
750
+ rules.push(RULES.exposurePositive);
751
+ if (hasInternalIntent) {
752
+ const declarationLocations = sortLocations(internalDeclarations.map((s) => s.location));
753
+ const positiveLocations = sortLocations(positiveExposure.map((s) => s.location));
754
+ const intentWhere = declarationLocations.length > 0
755
+ ? declarationLocations.map(locationText).join(', ')
756
+ : matchedInternalRules.map((rule) => `organization rule '${rule.reason}'`).join(', ');
757
+ const detail = `internal intent at ${intentWhere} contradicted by positive external signal at ` +
758
+ `${positiveLocations.map(locationText).join(', ')}; ` +
759
+ 'decision: user-facing (conservative) — remove the false internal declaration ' +
760
+ 'or remove the public exposure';
761
+ const locations = sortLocations([...declarationLocations, ...positiveLocations, resource.location]);
762
+ blocks.push({
763
+ code: 'CLASSIFICATION_CONTRADICTION',
764
+ resourceId: `${plane}.${resource.name}`,
765
+ name: resource.name,
766
+ detail,
767
+ locations,
768
+ });
769
+ contradictions.push({ dimension: 'exposure', detail, locations });
770
+ }
771
+ }
772
+ else if (hasInternalIntent) {
773
+ contribute(...internalDeclarations);
774
+ const reachability = signals.filter((s) => s.dimension === 'internality' && s.basis === 'code-positive');
775
+ const categories = [];
776
+ for (const signal of reachability) {
777
+ const category = internalityCategory(signal);
778
+ if (category !== null)
779
+ categories.push(category);
780
+ }
781
+ const untrusted = [
782
+ ...new Set(reachability
783
+ .filter((signal) => {
784
+ const category = internalityCategory(signal);
785
+ const entry = ctx.trustedEntries.find((candidate) => candidate.category === category);
786
+ return entry === undefined || (entry.patterns !== undefined && !entry.patterns.some((pattern) => globMatch(signal.location.file, pattern)));
787
+ })
788
+ .map((signal) => internalityCategory(signal) ?? '<unknown>')),
789
+ ];
790
+ if (!ctx.scanComplete) {
791
+ exposure = 'user-facing';
792
+ rules.push(RULES.exposureDefault);
793
+ defaultsApplied.push(RULES.exposureDefault);
794
+ blocks.push({
795
+ code: 'INCOMPLETE_PROOF_SCOPE',
796
+ resourceId: `${plane}.${resource.name}`,
797
+ name: resource.name,
798
+ detail: 'internal intent cannot be certified: the complete-scan attestation fails ' +
799
+ '(findings or unresolved entries intersect the configured scanRoots, or scanRoots are empty); ' +
800
+ 'decision: user-facing (conservative) until the scan scope is complete',
801
+ locations: [resource.location],
802
+ });
803
+ }
804
+ else if (!ctx.exposureComplete) {
805
+ exposure = 'user-facing';
806
+ rules.push(RULES.exposureDefault);
807
+ defaultsApplied.push(RULES.exposureDefault);
808
+ blocks.push({
809
+ code: 'INCOMPLETE_PROOF_SCOPE',
810
+ resourceId: `${plane}.${resource.name}`,
811
+ name: resource.name,
812
+ detail: 'internal intent cannot be certified: no EXHAUSTIVE exposure.* coverage rule covers ' +
813
+ 'every requested file with a detector-reported scan; reading a file with a ' +
814
+ 'non-exhaustive (e.g. regex) detector proves nothing about the absence of exposure, ' +
815
+ 'so internality stays unavailable for this scope until an exhaustive exposure ' +
816
+ 'parser is declared and covers it; decision: user-facing (conservative)',
817
+ locations: [resource.location],
818
+ });
819
+ }
820
+ else if (untrusted.length > 0) {
821
+ exposure = 'user-facing';
822
+ rules.push(RULES.exposureDefault);
823
+ defaultsApplied.push(RULES.exposureDefault);
824
+ blocks.push({
825
+ code: 'INCOMPLETE_PROOF_SCOPE',
826
+ resourceId: `${plane}.${resource.name}`,
827
+ name: resource.name,
828
+ detail: `internal intent cannot be certified: reachability signal(s) assert untrusted ` +
829
+ `entry-point category(es) ${untrusted.sort(compareStrings).join(', ')} ` +
830
+ `(trusted: ${[...ctx.trustedCategories].sort(compareStrings).join(', ') || '<none declared>'}); ` +
831
+ 'decision: user-facing (conservative)',
832
+ locations: sortLocations(reachability.map((s) => s.location)),
833
+ });
834
+ }
835
+ else if (categories.length === 0 || positiveLifecycle.length > 0) {
836
+ exposure = 'user-facing';
837
+ rules.push(RULES.exposureDefault);
838
+ defaultsApplied.push(RULES.exposureDefault);
839
+ blocks.push({
840
+ code: 'INCOMPLETE_PROOF_SCOPE',
841
+ resourceId: `${plane}.${resource.name}`,
842
+ name: resource.name,
843
+ detail: categories.length === 0
844
+ ? 'internal intent cannot be certified: no trusted-internal reachability signal ' +
845
+ `binds this resource (declared categories: ` +
846
+ `${[...ctx.trustedCategories].sort(compareStrings).join(', ') || '<none>'}); ` +
847
+ 'decision: user-facing (conservative)'
848
+ : 'internal intent cannot be certified: positive lifecycle mutation signal(s) exist ' +
849
+ 'in scope, so reachable entry points are not exclusively internal; ' +
850
+ 'decision: user-facing (conservative)',
851
+ locations: categories.length === 0
852
+ ? [resource.location]
853
+ : sortLocations(positiveLifecycle.map((s) => s.location)),
854
+ });
855
+ }
856
+ else {
857
+ exposure = 'internal';
858
+ rules.push(RULES.exposureInternalCertificate);
859
+ contribute(...reachability);
860
+ for (const rule of matchedInternalRules) {
861
+ rules.push(`${RULES.orgInternalRule}(${rule.reason})`);
862
+ }
863
+ }
864
+ }
865
+ else {
866
+ exposure = 'user-facing';
867
+ rules.push(RULES.exposureDefault);
868
+ defaultsApplied.push(RULES.exposureDefault);
869
+ }
870
+ // -- Lifecycle (per operation, independently) ------------------------------
871
+ // ADR 0004 D5/D8: HTTP endpoints carry no lifecycle lattice. Their
872
+ // route semantics live in the compiled capabilities attribute, and the
873
+ // policy engine never generates crud:*/persistence:* contracts against
874
+ // the endpoint kind — so lifecycle defaults (which exist to gate those
875
+ // contracts) have no honest meaning here and would only demand
876
+ // delete-semantics evidence no route can carry.
877
+ const lifecycle = { create: true, read: true, update: true, delete: true };
878
+ if (resource.kind === HTTP_ENDPOINT_RESOURCE_KIND) {
879
+ // ADR 0004 D5/D8: HTTP endpoints carry no lifecycle lattice. Their
880
+ // route semantics live in the compiled capabilities attribute, and
881
+ // the policy engine never generates crud:*/persistence:* contracts
882
+ // against the endpoint kind — so lifecycle defaults (which exist to
883
+ // gate those contracts) have no honest meaning here and would only
884
+ // demand delete-semantics evidence no route can carry.
885
+ lifecycle.create = false;
886
+ lifecycle.read = false;
887
+ lifecycle.update = false;
888
+ lifecycle.delete = false;
889
+ rules.push(RULES.lifecycleEndpointHttp);
890
+ }
891
+ else
892
+ for (const operation of OPERATIONS) {
893
+ const dimension = `lifecycle.${operation}`;
894
+ const positives = signals.filter((s) => s.dimension === dimension &&
895
+ s.basis === 'code-positive' &&
896
+ assertionBoolean(s.assertion) === true);
897
+ const closedWorld = pool.authority.filter((s) => s.dimension === dimension &&
898
+ s.basis === 'code-negative-closed-world' &&
899
+ assertionBoolean(s.assertion) === false);
900
+ const declaredSupported = signals.filter((s) => s.dimension === dimension &&
901
+ (s.basis === 'declaration' || s.basis === 'organization-policy') &&
902
+ assertionBoolean(s.assertion) === true);
903
+ const declaredUnsupported = signals.filter((s) => s.dimension === dimension &&
904
+ (s.basis === 'declaration' || s.basis === 'organization-policy') &&
905
+ assertionBoolean(s.assertion) === false);
906
+ if (positives.length > 0) {
907
+ contribute(...positives);
908
+ lifecycle[operation] = true;
909
+ rules.push(`${RULES.lifecyclePositive}(${operation})`);
910
+ if (declaredUnsupported.length > 0) {
911
+ const detail = `lifecycle.${operation} has both positive evidence and unsupported declarations; ` +
912
+ 'the operation stays enabled (conservative) and the conflict blocks';
913
+ const locations = sortLocations([...positives, ...declaredUnsupported].map((s) => s.location));
914
+ blocks.push({
915
+ code: 'LIFECYCLE_CONTRADICTION',
916
+ resourceId: `${plane}.${resource.name}`,
917
+ name: resource.name,
918
+ detail,
919
+ locations,
920
+ });
921
+ contradictions.push({ dimension, detail, locations });
922
+ }
923
+ }
924
+ else if (closedWorld.length > 0 && ctx.scanComplete && declaredSupported.length === 0) {
925
+ contribute(...closedWorld);
926
+ lifecycle[operation] = false;
927
+ rules.push(`${RULES.lifecycleClosedWorldDisabled}(${operation})`);
928
+ }
929
+ else if (closedWorld.length > 0) {
930
+ // A closed-world assertion whose proof does not hold: the intent to
931
+ // suppress is unproven, so the conservative default applies AND the
932
+ // unproven claim blocks (fail closed on suppressive evidence).
933
+ lifecycle[operation] = true;
934
+ defaultsApplied.push(`${RULES.lifecycleDefault}(${operation})`);
935
+ const reason = !ctx.scanComplete
936
+ ? 'the complete-scan attestation fails'
937
+ : 'contradicting supported declarations exist';
938
+ blocks.push({
939
+ code: 'INCOMPLETE_PROOF_SCOPE',
940
+ resourceId: `${plane}.${resource.name}`,
941
+ name: resource.name,
942
+ detail: `closed-world proof that lifecycle.${operation} is structurally unavailable fails: ` +
943
+ `${reason}; the operation stays enabled (conservative)`,
944
+ locations: sortLocations(closedWorld.map((s) => s.location)),
945
+ });
946
+ }
947
+ else if (declaredSupported.length > 0) {
948
+ contribute(...declaredSupported);
949
+ lifecycle[operation] = true;
950
+ rules.push(`${RULES.lifecycleDeclaredSupported}(${operation})`);
951
+ }
952
+ else if (declaredUnsupported.length > 0) {
953
+ lifecycle[operation] = true;
954
+ defaultsApplied.push(`${RULES.lifecycleDefault}(${operation})`);
955
+ }
956
+ else {
957
+ lifecycle[operation] = true;
958
+ defaultsApplied.push(`${RULES.lifecycleDefault}(${operation})`);
959
+ }
960
+ }
961
+ // Preserve normalized owner-declared update fields while excluding
962
+ // bookkeeping fields configured as volatile.
963
+ const updateableFields = resource.attributes['updateableFields'];
964
+ if (Array.isArray(updateableFields) &&
965
+ updateableFields.length > 0 &&
966
+ updateableFields.every((field) => typeof field === 'string' && field.length > 0)) {
967
+ const volatile = new Set(ctx.policy.volatileFields);
968
+ lifecycle.updateableFields = [...new Set(updateableFields.filter((field) => !volatile.has(field)))].sort(compareStrings);
969
+ }
970
+ // -- Delete semantics ------------------------------------------------------
971
+ if (lifecycle.delete) {
972
+ const semanticsSignals = signals.filter((s) => s.dimension === 'delete-semantics' &&
973
+ (s.assertion === 'hard' || s.assertion === 'archive'));
974
+ const archiveStateSignals = signals.filter((s) => s.dimension === 'archive-state' &&
975
+ typeof s.assertion === 'object' &&
976
+ !Array.isArray(s.assertion) &&
977
+ Object.keys(s.assertion).length > 0);
978
+ const semanticsValues = new Set();
979
+ for (const signal of semanticsSignals)
980
+ semanticsValues.add(signal.assertion);
981
+ if (semanticsValues.size === 1) {
982
+ const semantics = [...semanticsValues][0];
983
+ contribute(...semanticsSignals);
984
+ if (semantics === 'hard') {
985
+ lifecycle.deleteSemantics = 'hard';
986
+ rules.push(RULES.deleteProvenHard);
987
+ }
988
+ else if (archiveStateSignals.length > 0) {
989
+ contribute(...archiveStateSignals);
990
+ const records = archiveStateSignals.map((s) => s.assertion);
991
+ const merged = {};
992
+ const keys = [...new Set(records.flatMap((r) => Object.keys(r)))].sort(compareStrings);
993
+ let conflicting = false;
994
+ for (const key of keys) {
995
+ const values = [
996
+ ...new Set(records
997
+ .map((record) => record[key])
998
+ .filter((value) => value !== undefined)),
999
+ ];
1000
+ if (values.length > 1)
1001
+ conflicting = true;
1002
+ const first = values.sort(compareScalar)[0];
1003
+ if (first !== undefined)
1004
+ merged[key] = first;
1005
+ }
1006
+ if (conflicting) {
1007
+ blocks.push(deleteSemanticsBlock(resource, plane, 'archive-state signals declare conflicting archived values for the same field; ' +
1008
+ 'the owner-owned archived state is never guessed'));
1009
+ }
1010
+ else {
1011
+ lifecycle.deleteSemantics = 'archive';
1012
+ lifecycle.archiveFields = merged;
1013
+ rules.push(RULES.deleteProvenArchive);
1014
+ }
1015
+ }
1016
+ else {
1017
+ blocks.push(deleteSemanticsBlock(resource, plane, 'archive delete semantics require owner-owned archive-state evidence ' +
1018
+ '(the archived field values, e.g. {status: archived}); none was found'));
1019
+ }
1020
+ }
1021
+ else if (semanticsValues.size === 0) {
1022
+ blocks.push(deleteSemanticsBlock(resource, plane, 'delete is enabled but its semantics are unresolved: prove a hard delete, ' +
1023
+ 'prove archive semantics with owner-owned archive state, or close-world-disable delete'));
1024
+ }
1025
+ else {
1026
+ blocks.push(deleteSemanticsBlock(resource, plane, 'conflicting delete-semantics evidence (both hard and archive asserted); ' +
1027
+ 'the semantics are never guessed'));
1028
+ }
1029
+ }
1030
+ // -- Adapter binding -------------------------------------------------------
1031
+ // The reviewed EntityAdapter demand is a BUSINESS-resource requirement,
1032
+ // not a universal one (dogfood: 473/746 ADAPTER_MISSING blocks on pure
1033
+ // endpoints). An `http.endpoint` resource is witnessed through the
1034
+ // claims/witness-proxy lane (`http:frontend-request-observed` etc.);
1035
+ // EntityAdapter's contract — read by id, normalize a body, deletion
1036
+ // kind — is about business-entity persistence and has no honest meaning
1037
+ // for a route, so demanding one for every user-facing endpoint was a
1038
+ // category error that red the gate on evidence no route can carry.
1039
+ // Endpoints therefore classify user-facing WITHOUT an adapter (the
1040
+ // claims lane, recorded as `evidenceLane: 'claims'` so the schema and
1041
+ // every downstream consumer see WHY no adapter is present). The demand
1042
+ // is unchanged for business kinds: a user-facing table without a
1043
+ // reviewed adapter still blocks (ADAPTER_MISSING).
1044
+ const businessResource = resource.kind !== HTTP_ENDPOINT_RESOURCE_KIND;
1045
+ let evidenceAdapter;
1046
+ if (exposure === 'user-facing' && businessResource) {
1047
+ const bound = bindAdapter(ctx.adapters, resource, signals);
1048
+ if (bound !== null) {
1049
+ evidenceAdapter = bound;
1050
+ rules.push(RULES.adapterNameMatch);
1051
+ }
1052
+ else {
1053
+ blocks.push({
1054
+ code: 'ADAPTER_MISSING',
1055
+ resourceId: `${plane}.${resource.name}`,
1056
+ name: resource.name,
1057
+ detail: `user-facing resource '${plane}.${resource.name}' has no reviewed evidence adapter ` +
1058
+ `(looked for '${resource.id ?? resource.name}' and '${resource.name}' in the adapters ` +
1059
+ 'directory); implement the adapter to resolve',
1060
+ locations: [resource.location],
1061
+ });
1062
+ }
1063
+ }
1064
+ // -- Assemble --------------------------------------------------------------
1065
+ const classification = {
1066
+ exposure,
1067
+ plane,
1068
+ lifecycle,
1069
+ primaryKey,
1070
+ ...(exposure === 'user-facing' && !businessResource ? { evidenceLane: 'claims' } : {}),
1071
+ ...(evidenceAdapter !== undefined ? { evidenceAdapter } : {}),
1072
+ };
1073
+ const signalIds = [...new Set(contributing.map((s) => signalId(s)))].sort(compareStrings);
1074
+ const contributingDetectors = [
1075
+ ...new Set(contributing.map((s) => `${s.detector.id}@${s.detector.version}`)),
1076
+ ].sort(compareStrings);
1077
+ const sortedRules = [...new Set(rules)].sort(compareStrings);
1078
+ const sortedDefaults = [...new Set(defaultsApplied)].sort(compareStrings);
1079
+ const sortedContradictions = sortContradictions(contradictions);
1080
+ const fingerprint = decisionFingerprint({
1081
+ resourceId: resource.id,
1082
+ name: resource.name,
1083
+ kind: resource.kind,
1084
+ classification,
1085
+ rules: sortedRules,
1086
+ defaultsApplied: sortedDefaults,
1087
+ signalIds,
1088
+ detectors: contributingDetectors,
1089
+ contradictions: sortedContradictions,
1090
+ });
1091
+ const trace = {
1092
+ rules: sortedRules,
1093
+ defaultsApplied: sortedDefaults,
1094
+ contributingSignalIds: signalIds,
1095
+ contributingDetectors,
1096
+ contradictions: sortedContradictions,
1097
+ unresolvedDimensions: [
1098
+ ...new Set(blocks.map((block) => BLOCK_DIMENSIONS[block.code] ?? block.code)),
1099
+ ].sort(compareStrings),
1100
+ decisionFingerprint: fingerprint,
1101
+ };
1102
+ // The classification must always be a VALID Classification (the same
1103
+ // schema policy/verdict code consumes). Definitional blocks — delete
1104
+ // semantics unresolved, missing adapter — make it invalid; those keep
1105
+ // only the typed blocks. Contradiction/incomplete-proof blocks carry a
1106
+ // valid conservative classification (obligations still accrue).
1107
+ const valid = ClassificationSchema.safeParse(classification);
1108
+ if (!valid.success) {
1109
+ return {
1110
+ resourceId: resource.id,
1111
+ name: resource.name,
1112
+ kind: resource.kind,
1113
+ source: resource.source,
1114
+ location: resource.location,
1115
+ classification: null,
1116
+ blocks: sortBlocks(blocks),
1117
+ };
1118
+ }
1119
+ return {
1120
+ resourceId: resource.id,
1121
+ name: resource.name,
1122
+ kind: resource.kind,
1123
+ source: resource.source,
1124
+ location: resource.location,
1125
+ blocks: sortBlocks(blocks),
1126
+ classification: { ...valid.data, ...trace },
1127
+ };
1128
+ }
1129
+ /** Adapter binding: an explicit adapter-binding signal first, then id, then bare name. */
1130
+ function bindAdapter(adapters, resource, signals) {
1131
+ const exists = (name) => name.length > 0 && adapters.includes(name);
1132
+ for (const signal of signals) {
1133
+ if (signal.dimension === 'adapter-binding' && typeof signal.assertion === 'string') {
1134
+ if (exists(signal.assertion))
1135
+ return signal.assertion;
1136
+ }
1137
+ }
1138
+ if (resource.id !== null && exists(resource.id))
1139
+ return resource.id;
1140
+ if (exists(resource.name))
1141
+ return resource.name;
1142
+ return null;
1143
+ }
1144
+ /** Terminal helper: a resource whose lattice run produced only blocks. */
1145
+ function blocked(resource, blocks) {
1146
+ return {
1147
+ resourceId: resource.id,
1148
+ name: resource.name,
1149
+ kind: resource.kind,
1150
+ source: resource.source,
1151
+ location: resource.location,
1152
+ classification: null,
1153
+ blocks: sortBlocks(blocks),
1154
+ };
1155
+ }
1156
+ function planeUnresolvedBlock(resource, cause) {
1157
+ return {
1158
+ code: 'PLANE_UNRESOLVED',
1159
+ resourceId: resource.id,
1160
+ name: resource.name,
1161
+ detail: `resource '${resource.name}' has no derivable plane: ${cause}`,
1162
+ locations: [resource.location],
1163
+ };
1164
+ }
1165
+ function identityUnresolvedBlock(resource, cause) {
1166
+ return {
1167
+ code: 'IDENTITY_UNRESOLVED',
1168
+ resourceId: resource.id,
1169
+ name: resource.name,
1170
+ detail: `resource '${resource.name}' has no derivable primary key: ${cause}`,
1171
+ locations: [resource.location],
1172
+ };
1173
+ }
1174
+ function deleteSemanticsBlock(resource, plane, cause) {
1175
+ return {
1176
+ code: 'DELETE_SEMANTICS_UNRESOLVED',
1177
+ resourceId: `${plane}.${resource.name}`,
1178
+ name: resource.name,
1179
+ detail: `resource '${plane}.${resource.name}': ${cause}`,
1180
+ locations: [resource.location],
1181
+ };
1182
+ }
1183
+ function joinKeys(columns) {
1184
+ return JSON.stringify(columns);
1185
+ }
1186
+ /**
1187
+ * Deterministic total order over archive-field scalar values (fixed
1188
+ * type-first ordering: boolean < number < string, then by value). Used
1189
+ * only to pick a representative when detecting conflicts — conflicting
1190
+ * values block regardless; the order keeps output byte-stable.
1191
+ */
1192
+ function compareScalar(a, b) {
1193
+ const rank = (value) => typeof value === 'boolean' ? 0 : typeof value === 'number' ? 1 : 2;
1194
+ const byRank = rank(a) - rank(b);
1195
+ if (byRank !== 0)
1196
+ return byRank;
1197
+ if (typeof a === 'string' && typeof b === 'string')
1198
+ return compareStrings(a, b);
1199
+ if (typeof a === 'number' && typeof b === 'number')
1200
+ return a - b;
1201
+ return a === b ? 0 : (a === true ? 1 : 0) - (b === true ? 1 : 0);
1202
+ }
1203
+ function splitKeys(joined) {
1204
+ const parsed = JSON.parse(joined);
1205
+ return Array.isArray(parsed) ? parsed.map(String) : [];
1206
+ }
1207
+ //# sourceMappingURL=classify.js.map