@aiwg/cli 2026.10.11 → 2026.10.12

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 (132) hide show
  1. package/agentic/code/providers/model-catalog.v1.json +62 -12
  2. package/dist/src/artifacts/binding-target-identity.js +51 -0
  3. package/dist/src/artifacts/checksum-manifest.js +12 -0
  4. package/dist/src/artifacts/index-builder.js +11 -13
  5. package/dist/src/artifacts/query-engine.js +5 -11
  6. package/dist/src/cli/handlers/doctor-help.js +32 -0
  7. package/dist/src/cli/handlers/gates.js +19 -18
  8. package/dist/src/cli/handlers/models-help.js +7 -1
  9. package/dist/src/cli/handlers/models.js +3 -3
  10. package/dist/src/cli/handlers/script-runner.js +8 -5
  11. package/dist/src/cli/handlers/sessions-intake.js +104 -0
  12. package/dist/src/cli/handlers/sessions-tail.js +172 -0
  13. package/dist/src/cli/handlers/sessions.js +90 -17
  14. package/dist/src/cli/handlers/skill-lint.js +10 -3
  15. package/dist/src/cli/handlers/steward-format.js +100 -0
  16. package/dist/src/cli/handlers/steward-models.js +167 -0
  17. package/dist/src/cli/handlers/steward.js +14 -248
  18. package/dist/src/cli/handlers/use-bundle-counts.js +61 -0
  19. package/dist/src/cli/handlers/use.js +6 -24
  20. package/dist/src/cli/handlers/utilities.js +1 -24
  21. package/dist/src/config/aiwg-config.js +7 -0
  22. package/dist/src/config/authorization-cli.js +89 -0
  23. package/dist/src/config/cli-help.js +1 -0
  24. package/dist/src/config/cli.js +10 -1
  25. package/dist/src/decision/context-pruning.js +3 -2
  26. package/dist/src/decision/ensemble/contract.js +15 -4
  27. package/dist/src/decision/ensemble-study/artifacts.js +5 -0
  28. package/dist/src/decision/ensemble-study/score.js +4 -3
  29. package/dist/src/decision/feature-export.js +3 -9
  30. package/dist/src/decision/heldout/collector.js +2 -2
  31. package/dist/src/decision/issue-triage/runtime.js +3 -9
  32. package/dist/src/decision/qualification/gates.js +2 -1
  33. package/dist/src/decision/qualification/release.js +2 -3
  34. package/dist/src/decision/routing/report.js +2 -0
  35. package/dist/src/decision/sdlc-screening.js +3 -5
  36. package/dist/src/decision/sensitivity/comparative.js +5 -3
  37. package/dist/src/gates/driver.js +6 -77
  38. package/dist/src/gates/evaluate.js +4 -11
  39. package/dist/src/gates/floors.js +4 -4
  40. package/dist/src/gates/index.js +3 -3
  41. package/dist/src/gates/lattice.js +26 -0
  42. package/dist/src/gates/registry.js +2 -1
  43. package/dist/src/gates/report.js +19 -7
  44. package/dist/src/issues/audit-cli.js +120 -0
  45. package/dist/src/issues/audit-contract.js +64 -0
  46. package/dist/src/issues/audit-local.js +82 -0
  47. package/dist/src/issues/audit.js +220 -0
  48. package/dist/src/lint/rule-activation.js +15 -1
  49. package/dist/src/mission-protocol/canonical-codecs.js +270 -0
  50. package/dist/src/mission-protocol/codecs.js +22 -550
  51. package/dist/src/mission-protocol/health-trace-codecs.js +281 -0
  52. package/dist/src/mission-protocol/health-trace-types.js +43 -0
  53. package/dist/src/mission-protocol/types.js +2 -42
  54. package/dist/src/mission-protocol/worker-contract-json.js +10 -0
  55. package/dist/src/mission-protocol/worker-review-gate.js +100 -0
  56. package/dist/src/mission-protocol/worker-review-types.js +2 -0
  57. package/dist/src/models/catalog-policy.js +280 -0
  58. package/dist/src/models/index.js +1 -1
  59. package/dist/src/models/model-catalog.v1.json +62 -12
  60. package/dist/src/models/policy-resources.js +28 -0
  61. package/dist/src/models/provider-policy.js +10 -546
  62. package/dist/src/models/resolver.js +26 -8
  63. package/dist/src/models/router.js +15 -2
  64. package/dist/src/models/tiers.js +13 -0
  65. package/dist/src/models/types.js +0 -6
  66. package/dist/src/models/worker-ceilings.js +59 -0
  67. package/dist/src/models/worker-invocation-policy.js +243 -0
  68. package/dist/src/models/worker-plan.js +16 -20
  69. package/dist/src/models/worker-review-lineage.js +55 -0
  70. package/dist/src/models/worker-selection-policy.js +1 -1
  71. package/dist/src/models/wrapper-route.js +6 -3
  72. package/dist/src/policy/authorization.js +1 -0
  73. package/dist/src/policy/hitl-config.js +63 -0
  74. package/dist/src/policy/hitl-receipt.js +57 -0
  75. package/dist/src/policy/hitl-types.js +4 -0
  76. package/dist/src/policy/hitl-validation.js +39 -0
  77. package/dist/src/policy/hitl.js +117 -0
  78. package/dist/src/rules/activation.d.mts +1 -1
  79. package/dist/src/rules/activation.mjs +0 -2
  80. package/dist/src/rules/gate-coverage.js +51 -0
  81. package/dist/src/rules/gate-references.js +16 -0
  82. package/dist/src/rules/rule-frontmatter.schema.json +7 -1
  83. package/dist/src/serve/worker-command-request.js +21 -4
  84. package/dist/src/serve/worker-invocation-preparation.js +3 -1
  85. package/dist/src/serve/worker-invocation.js +21 -10
  86. package/dist/src/sessions/adapters/deepseek-harness.js +1 -0
  87. package/dist/src/sessions/adapters/grok-build-native.js +269 -0
  88. package/dist/src/sessions/adapters/grok-build.js +57 -15
  89. package/dist/src/sessions/adapters/hermes-state-db.js +252 -0
  90. package/dist/src/sessions/adapters/hermes.js +42 -3
  91. package/dist/src/sessions/adapters/warp.js +1 -1
  92. package/dist/src/sessions/batch-import.js +10 -0
  93. package/dist/src/sessions/contracts.js +1 -1
  94. package/dist/src/sessions/discovery-capability.js +38 -0
  95. package/dist/src/sessions/importer.js +8 -251
  96. package/dist/src/sessions/index.js +4 -0
  97. package/dist/src/sessions/intake-dir.js +71 -0
  98. package/dist/src/sessions/normalization.js +251 -0
  99. package/dist/src/sessions/tail.js +91 -0
  100. package/dist/src/sessions/workspace-discovery.js +83 -7
  101. package/dist/src/skills/agent-skill-sidecar.js +28 -0
  102. package/dist/src/skills/agent-skills.js +4 -23
  103. package/dist/src/skills/claude-surface-projection.js +25 -0
  104. package/dist/src/skills/deployer.js +8 -288
  105. package/dist/src/skills/deployment-policy.js +114 -0
  106. package/dist/src/skills/deployment-projection.js +152 -0
  107. package/dist/src/skills/deployment-types.js +20 -0
  108. package/dist/src/skills/exporter.js +8 -31
  109. package/dist/src/skills/provider-extensions.js +41 -0
  110. package/dist/src/skills/run.js +6 -0
  111. package/dist/src/skills/types.js +0 -8
  112. package/dist/src/skills/validator-resources.js +68 -0
  113. package/dist/src/skills/validator.js +6 -55
  114. package/dist/src/storage/cli-migrate-options.js +125 -0
  115. package/dist/src/storage/cli-migrate.js +111 -0
  116. package/dist/src/storage/cli.js +4 -279
  117. package/dist/src/storage/migration-adapter.js +79 -0
  118. package/dist/src/storage/migration-copy.js +108 -0
  119. package/dist/src/storage/migration-protocol.js +14 -197
  120. package/dist/src/storage/migration-records.js +186 -0
  121. package/dist/src/storage/migration-types.js +12 -0
  122. package/dist/src/tracker/forge-http.js +2 -1
  123. package/dist/src/tracker/issue-pages.js +110 -0
  124. package/package.json +1 -1
  125. package/schemas/mission-v1.schema.json +335 -0
  126. package/schemas/models/model-catalog.v1.schema.json +19 -1
  127. package/schemas/models/model-config-compatibility.v1.schema.json +3 -2
  128. package/schemas/models/model-policy.v1.schema.json +1 -1
  129. package/tools/agents/providers/provider-paths-staging.mjs +35 -0
  130. package/tools/agents/providers/rule-binding-targets.mjs +146 -0
  131. package/tools/agents/providers/rule-delivery.mjs +7 -126
  132. package/vscode-extension/schemas/aiwg.config.v1.json +177 -0
@@ -1,3 +1,4 @@
1
+ import { validateHitlPolicy } from '../policy/hitl-validation.js';
1
2
  import { normalizeAiwgConfigCandidate } from './config-normalization.js';
2
3
  import { atomicConfigWrite, withWorkerWriteLock } from './worker-write.js';
3
4
  import { workerConfigDigest } from './worker-config-digest.js';
@@ -979,6 +980,9 @@ export async function readAiwgConfigCandidate(projectDir) {
979
980
  if (workspaceErrors.length > 0) {
980
981
  throw new Error(`Invalid .aiwg/aiwg.config:\n${workspaceErrors.join('\n')}`);
981
982
  }
983
+ const hitlErrors = validateHitlPolicy(parsed.authorization?.hitl);
984
+ if (hitlErrors.length)
985
+ throw new Error(hitlErrors.join("\n"));
982
986
  const authorizationErrors = parsed.authorization
983
987
  ? validateAuthorization(parsed.authorization).filter(item => item.severity === 'error')
984
988
  : [];
@@ -1050,6 +1054,9 @@ async function writeAiwgConfigUnlocked(projectDir, config) {
1050
1054
  throw error;
1051
1055
  }
1052
1056
  }
1057
+ const hitlErrors = validateHitlPolicy(config.authorization?.hitl);
1058
+ if (hitlErrors.length)
1059
+ throw new Error(hitlErrors.join("\n"));
1053
1060
  const pinConfigs = loadRulePinConfigs(projectDir, config);
1054
1061
  const currentCapabilities = new Set(installedRuleCapabilities(pinConfigs));
1055
1062
  const removed = previous ? installedRuleCapabilities({ ...pinConfigs, project: previous }).filter(id => !currentCapabilities.has(id)) : [];
@@ -0,0 +1,89 @@
1
+ // Exposes HITL inspection, validation, preview, enable/revoke and signed receipts under config/steward.
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { AiwgError, EXIT_CODES } from '../cli/errors.js';
5
+ import { getProjectDir, readAiwgConfig, writeAiwgConfig } from './aiwg-config.js';
6
+ import { ensureAuthorization, loadHitlLayers } from '../policy/hitl-config.js';
7
+ import { resolveHitlAuthorization } from '../policy/hitl.js';
8
+ import { validateHitlPolicy } from '../policy/hitl-validation.js';
9
+ export const authorizationUsage = `aiwg config authorization (also aiwg steward authorization)
10
+ inspect | validate [--target DIRECTORY]
11
+ preview --action-file FILE [--target DIRECTORY]
12
+ enable --policy-file FILE [--target DIRECTORY]
13
+ revoke [--profile NAME] [--target DIRECTORY]
14
+ receipt --action-file FILE [--target DIRECTORY]
15
+ Preview is read-only. Receipt writes the decision to the existing signed effect ledger;
16
+ it does not perform the proposed action. Action files cannot supply session authorization.`;
17
+ const usageError = (message) => new AiwgError({ code: 'ERR_AUTHORIZATION_USAGE', message, exitCode: EXIT_CODES.USAGE });
18
+ async function jsonFile(file) {
19
+ try {
20
+ return JSON.parse(await readFile(file, 'utf8'));
21
+ }
22
+ catch {
23
+ throw usageError('Authorization JSON input could not be read or parsed');
24
+ }
25
+ }
26
+ export async function authorizationCli(args, cwd = process.cwd()) {
27
+ if (!args.length || args.includes('--help') || args[0] === 'help') {
28
+ console.log(authorizationUsage);
29
+ return;
30
+ }
31
+ const [operation, ...rest] = args;
32
+ const flags = {};
33
+ const allowed = ['--target', ...(operation === 'enable' ? ['--policy-file'] : []),
34
+ ...(['preview', 'receipt'].includes(operation) ? ['--action-file'] : []), ...(operation === 'revoke' ? ['--profile'] : [])];
35
+ for (let i = 0; i < rest.length; i += 2) {
36
+ if (!allowed.includes(rest[i]) || !rest[i + 1] || rest[i + 1].startsWith('--') || flags[rest[i]])
37
+ throw usageError('Invalid authorization option');
38
+ flags[rest[i]] = rest[i + 1];
39
+ }
40
+ const target = resolve(getProjectDir({ cwd }, rest));
41
+ if (operation === 'inspect' || operation === 'validate') {
42
+ const loaded = await loadHitlLayers(target);
43
+ console.log(JSON.stringify(operation === 'inspect' ? loaded : { valid: true, layers: loaded.layers.length }, null, 2));
44
+ return;
45
+ }
46
+ if (operation === 'preview' || operation === 'receipt') {
47
+ if (!flags['--action-file'])
48
+ throw usageError('--action-file is required');
49
+ const action = await jsonFile(resolve(cwd, flags['--action-file']));
50
+ const loaded = await loadHitlLayers(target);
51
+ const context = { now: new Date().toISOString(), layers: loaded.layers };
52
+ if (operation === 'preview')
53
+ console.log(JSON.stringify(resolveHitlAuthorization(action, context), null, 2));
54
+ else {
55
+ const { effectSession, openLedger } = await import('../effects/host.js');
56
+ const { recordHitlAuthorization } = await import('../policy/hitl-receipt.js');
57
+ const session = await effectSession(loaded.projectDir, 'custom');
58
+ console.log(JSON.stringify(await recordHitlAuthorization(openLedger(session), action, context), null, 2));
59
+ }
60
+ return;
61
+ }
62
+ if (operation !== 'enable' && operation !== 'revoke')
63
+ throw usageError('Unknown authorization operation');
64
+ const config = await readAiwgConfig(target);
65
+ if (!config)
66
+ throw usageError('No project config found; initialize project config first');
67
+ const authorization = ensureAuthorization(config);
68
+ if (operation === 'enable') {
69
+ if (!flags['--policy-file'])
70
+ throw usageError('--policy-file is required');
71
+ const policy = await jsonFile(resolve(cwd, flags['--policy-file']));
72
+ const errors = validateHitlPolicy(policy);
73
+ if (policy === undefined || errors.length)
74
+ throw usageError('Invalid authorization.hitl policy');
75
+ authorization.hitl = { ...policy, enabled: true };
76
+ }
77
+ else if (authorization.hitl) {
78
+ if (flags['--profile']) {
79
+ if (!Object.hasOwn(authorization.hitl.profiles, flags['--profile']))
80
+ throw usageError('Unknown authorization profile');
81
+ delete authorization.hitl.profiles[flags['--profile']];
82
+ }
83
+ else
84
+ authorization.hitl.enabled = false;
85
+ }
86
+ await writeAiwgConfig(target, config);
87
+ console.log(JSON.stringify({ operation, enabled: authorization.hitl?.enabled ?? false }));
88
+ }
89
+ //# sourceMappingURL=authorization-cli.js.map
@@ -17,6 +17,7 @@ Subcommands:
17
17
  path Print config directory path
18
18
  edit Open config in $EDITOR/$VISUAL (requires a terminal)
19
19
  edit --print-path Print the config file path instead of opening an editor
20
+ authorization Inspect/validate/preview/enable/revoke bounded HITL policy
20
21
  gitignore Show/check/fix .gitignore AIWG entries
21
22
 
22
23
  Reviewed worker policy pair (project scope, no prompts):
@@ -33,6 +33,11 @@ const _scriptDir = path.dirname(fileURLToPath(import.meta.url));
33
33
  * Main CLI entry point for `aiwg config <subcommand> [args]`
34
34
  */
35
35
  export async function main(args) {
36
+ if (args[0] === 'authorization') {
37
+ const { authorizationCli } = await import('./authorization-cli.js');
38
+ await authorizationCli(args.slice(1));
39
+ return;
40
+ }
36
41
  if (await handleWorkerAdoptionArgs(args, process.cwd(), 'config'))
37
42
  return;
38
43
  // Extract --config-dir flag before routing
@@ -291,7 +296,7 @@ async function projectConfigSet(key, raw, args, quiet = false) {
291
296
  }
292
297
  // Coerce booleans for known boolean fields
293
298
  let value = raw;
294
- if (key === 'rules' || key.startsWith('rules.') || key === 'parallelism' || /^parallelism\.(worker_dispatch|worker_mode|groups|clis|admission|budgets|health|retry|activation)(\.|$)/.test(key)) {
299
+ if (key === 'authorization' || key.startsWith('authorization.') || key === 'rules' || key.startsWith('rules.') || key === 'parallelism' || /^parallelism\.(worker_dispatch|worker_mode|groups|clis|admission|budgets|health|retry|activation)(\.|$)/.test(key)) {
295
300
  try {
296
301
  value = JSON.parse(raw);
297
302
  }
@@ -397,6 +402,10 @@ async function projectConfigSet(key, raw, args, quiet = false) {
397
402
  ? (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : emptyConfig())
398
403
  : (await readAiwgConfig(projectDir)) ?? emptyConfig();
399
404
  const before = structuredClone(getDottedPath(cfg, key));
405
+ if (key.startsWith('authorization.hitl')) {
406
+ const { ensureAuthorization } = await import('../policy/hitl-config.js');
407
+ ensureAuthorization(cfg);
408
+ }
400
409
  setDottedPath(cfg, key, value);
401
410
  const externalLinkErrors = validateExternalLinks(cfg.externalLinks);
402
411
  if (externalLinkErrors.length > 0) {
@@ -1,3 +1,4 @@
1
+ import { ceilingDecision } from '../gates/lattice.js';
1
2
  import { ContextBudgetManager } from '../metrics/context-budget.js';
2
3
  import { sha256 } from './compile-cache/identity.js';
3
4
  import { admitEntry } from './entry.js';
@@ -436,9 +437,9 @@ export function buildContextPruningEvaluationReport(input) {
436
437
  findings.add(`missing-input:${name}`);
437
438
  // Automatic rollback triggers from the D26 rollout plan: protected miss, demonstrated quality harm, negative
438
439
  // economics. An inconclusive non-inferiority result (no evidence of harm) holds instead.
439
- const rollback = upstreamDecision === 'ROLLBACK' || findings.has('protected-retention-breach')
440
+ const rollback = findings.has('protected-retention-breach')
440
441
  || findings.has('quality-harm') || findings.has('negative-net-economics');
441
- const decision = rollback ? 'ROLLBACK' : upstreamDecision === 'HOLD' || findings.size > 0 ? 'HOLD' : 'PROMOTE';
442
+ const decision = ceilingDecision(upstreamDecision, rollback ? 'ROLLBACK' : findings.size > 0 ? 'HOLD' : 'PROMOTE');
442
443
  const advisory = [...findings].some(item => item.startsWith('insufficient-') || item.startsWith('quality-metric-missing:')
443
444
  || item.startsWith('missing-input:') || item.startsWith('quality-outcomes-incomplete:')
444
445
  || item.startsWith('quality-non-inferiority-inconclusive:')
@@ -1,3 +1,4 @@
1
+ import { assertDecisionCeiling, ceilingDecision, GATE_RANK, GateDecisionUpgradeError } from '../../gates/lattice.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { dirname, resolve } from 'node:path';
@@ -10,8 +11,8 @@ import { admitEntry } from '../entry.js';
10
11
  export class EnsembleContractError extends Error {
11
12
  layer;
12
13
  details;
13
- constructor(message, layer = 'semantic', details = []) {
14
- super(message);
14
+ constructor(message, layer = 'semantic', details = [], cause) {
15
+ super(message, { cause });
15
16
  this.layer = layer;
16
17
  this.details = details;
17
18
  this.name = 'EnsembleContractError';
@@ -41,7 +42,6 @@ export const ENSEMBLE_DISAGREEMENT_PRIMITIVES = {
41
42
  const UNCERTAINTY_COMBINING = new Set(['mean-probability-v1', 'jensen-shannon-v1']);
42
43
  export const REQUIRED_PAIRED_METRICS = ['quality', 'calibration', 'risk-coverage', 'abstention', 'latency', 'tokens', 'cost', 'slice'];
43
44
  const CEILING_FIELDS = ['members', 'attempts', 'deadlineMs', 'tokens', 'costMicros', 'concurrency', 'fallbackDepth'];
44
- const GATE_RANK = { PROMOTE: 0, HOLD: 1, ROLLBACK: 2 };
45
45
  let validators = null;
46
46
  function schemaValidator(kind) {
47
47
  if (!validators) {
@@ -334,7 +334,7 @@ export function pairedDeltaFinding(threshold, item) {
334
334
  export function ensembleIntegrityDecision(integrity, findingCount) {
335
335
  const upstream = integrity.release_gate.decision;
336
336
  const compromised = integrity.integrity_state === 'compromised' || integrity.compromise_labels.length > 0;
337
- return upstream === 'ROLLBACK' || compromised ? 'ROLLBACK' : upstream === 'HOLD' || findingCount > 0 ? 'HOLD' : 'PROMOTE';
337
+ return ceilingDecision(upstream, compromised ? 'ROLLBACK' : 'PROMOTE', findingCount > 0 ? 'HOLD' : 'PROMOTE');
338
338
  }
339
339
  /** A D17 report may keep or tighten the upstream #2037/#2048 decision, never loosen it. Every
340
340
  * finding, passed flag and the decision are recomputed from the carried integrity fields and the
@@ -346,6 +346,15 @@ export function validateEnsembleIntegrityReport(value) {
346
346
  const problems = [];
347
347
  if (report.upstreamDecision !== report.integrity.release_gate.decision)
348
348
  problems.push('upstream decision must be the eval-integrity release gate decision');
349
+ let upgrade;
350
+ try {
351
+ assertDecisionCeiling(report.decision, report.upstreamDecision, ensembleIntegrityDecision(report.integrity, report.findings.length));
352
+ }
353
+ catch (error) {
354
+ if (!(error instanceof GateDecisionUpgradeError))
355
+ throw error;
356
+ upgrade = error;
357
+ }
349
358
  if (GATE_RANK[report.decision] < GATE_RANK[report.upstreamDecision])
350
359
  problems.push(`D17 cannot upgrade ${report.upstreamDecision} to ${report.decision}`);
351
360
  if (report.decision === 'PROMOTE' && (report.findings.length > 0 || report.pairedDeltas.some(item => !item.passed)))
@@ -372,6 +381,8 @@ export function validateEnsembleIntegrityReport(value) {
372
381
  problems.push(`decision ${report.decision} does not follow from its integrity and findings (${decision})`);
373
382
  if (digest !== ensembleContractDigest(payload))
374
383
  problems.push('integrity report digest does not match its content');
384
+ if (upgrade)
385
+ throw new EnsembleContractError('ensemble integrity report rejected', 'semantic', problems, upgrade);
375
386
  semantic(problems, 'ensemble integrity report rejected');
376
387
  return report;
377
388
  }
@@ -1,3 +1,4 @@
1
+ import { assertDecisionCeiling, ceilingDecision } from '../../gates/lattice.js';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import { dirname, resolve } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
@@ -32,6 +33,10 @@ export function validateD17Artifact(name, value) {
32
33
  admitEntry(value, name === 'report' || name === 'reportV2' ? reportLimits : limits);
33
34
  if (!validators[name]?.(value))
34
35
  throw new Error(`D17 invalid ${name} artifact`);
36
+ if (name === 'report' || name === 'reportV2') {
37
+ const report = value;
38
+ assertDecisionCeiling(report.decision, ceilingDecision('HOLD', report.integrity.release_gate.decision, report.integrity.compromise_labels.length ? 'ROLLBACK' : 'PROMOTE'));
39
+ }
35
40
  if (name === 'splits') {
36
41
  const manifest = value;
37
42
  const ids = new Set(), inputs = new Set(), families = new Map();
@@ -1,3 +1,4 @@
1
+ import { ceilingDecision } from '../../gates/lattice.js';
1
2
  import { heldoutDigest, validateHeldoutAttempt, validateHeldoutInputs } from '../heldout/contract.js';
2
3
  import { artifactPin } from '../validate.js';
3
4
  import { aggregateEnsembleResults } from '../ensemble/aggregate.js';
@@ -145,7 +146,7 @@ export async function scoreD17Study(input, prepared) {
145
146
  calibrationDiagnostics: { champion: binary('champion'), challenger: binary('challenger'), calibrated: false },
146
147
  pending: ['D09 compatible member and aggregate calibration', 'blind gold audit and delayed repeats', 'separate extra-cost tradeoff approval',
147
148
  'protected native report approval and eligibility'],
148
- decision: input.integrity.release_gate.decision === 'ROLLBACK' || input.integrity.compromise_labels.length ? 'ROLLBACK' : 'HOLD' };
149
+ decision: ceilingDecision('HOLD', input.integrity.release_gate.decision, input.integrity.compromise_labels.length ? 'ROLLBACK' : 'PROMOTE') };
149
150
  validateD17Artifact('report', report);
150
151
  return report;
151
152
  }
@@ -325,7 +326,7 @@ export async function scoreD17StagedStudy(input, prepared, context) {
325
326
  mappingDigest: heldoutDigest(verified[role].mapping), compatibility: { action: 'allow',
326
327
  state: verified[role].compatibility.state, pinId: verified[role].compatibility.pinId } });
327
328
  const approved = input.approvedCalibration;
328
- const decision = input.integrity.release_gate.decision === 'ROLLBACK' || input.integrity.compromise_labels.length ? 'ROLLBACK' : 'HOLD';
329
+ const decision = ceilingDecision('HOLD', input.integrity.release_gate.decision, input.integrity.compromise_labels.length ? 'ROLLBACK' : 'PROMOTE');
329
330
  const report = { schemaVersion: 'decision-d17-study-report/v2', scope: 'synthetic-staged-calibrated',
330
331
  calibrated: true, d09Qualified: true, calibratedGate: true,
331
332
  corpusDigest: heldoutDigest(prepared.corpus), preregistrationDigest: heldoutDigest(prepared.preregistration),
@@ -453,7 +454,7 @@ export function d17NativeIntegrity(input) {
453
454
  // The ensemble integrity contract carries only the gate decision and reasons (eval-tool thresholds are dropped).
454
455
  const gate = { decision: input.base.release_gate.decision, reasons: [...input.base.release_gate.reasons] };
455
456
  const release_gate = gate.decision === 'PROMOTE' && missing.length
456
- ? { decision: 'HOLD', reasons: [...gate.reasons, ...missing.map(reason => `promotion withheld: ${reason}`)] } : gate;
457
+ ? { decision: ceilingDecision(gate.decision, 'HOLD'), reasons: [...gate.reasons, ...missing.map(reason => `promotion withheld: ${reason}`)] } : gate;
457
458
  const { sample_n, uncertainty, integrity_mode, fresh_workspace_required, fresh_workspace_verified, integrity_state, trusted_score_source, compromise_labels, weak_signal_reason } = input.base;
458
459
  return { sample_n, uncertainty, paired_baseline: { ...input.pairedBaseline, d17TradeoffApprovalDigest: tradeoffDigest },
459
460
  integrity_mode, fresh_workspace_required, fresh_workspace_verified, integrity_state, trusted_score_source,
@@ -1,3 +1,4 @@
1
+ import { assertDecisionCeiling } from '../gates/lattice.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { dirname, resolve } from 'node:path';
@@ -942,9 +943,7 @@ function normalizeEvalIntegrity(value) {
942
943
  if (value.upstreamDisposition !== value.qualificationRelease.decision) {
943
944
  throw new DecisionFeatureExportError('Feature export integrity must preserve the upstream qualification release decision');
944
945
  }
945
- if (integrityRank(value.disposition) < integrityRank(value.upstreamDisposition)) {
946
- throw new DecisionFeatureExportError(`Feature export integrity cannot upgrade ${value.upstreamDisposition} to ${value.disposition}`);
947
- }
946
+ assertDecisionCeiling(value.disposition, value.upstreamDisposition);
948
947
  const { digest, ...payload } = value;
949
948
  if (digest !== digestEvalIntegrity(payload))
950
949
  throw new DecisionFeatureExportError('Feature export integrity digest does not match its content');
@@ -966,9 +965,7 @@ function validateQualificationRelease(record) {
966
965
  throw new DecisionFeatureExportError('Feature export qualification release digest does not match its content');
967
966
  }
968
967
  validateQualificationIntegrity(record.integrity);
969
- if (integrityRank(record.decision) < integrityRank(record.integrity.release_gate.decision)) {
970
- throw new DecisionFeatureExportError('Feature export qualification release cannot upgrade its integrity release gate');
971
- }
968
+ assertDecisionCeiling(record.decision, record.integrity.release_gate.decision, record.integrity.integrity_state === 'compromised' || record.integrity.compromise_labels.length ? 'ROLLBACK' : 'PROMOTE', ...record.gates.map(gate => gate.status === 'pass' ? 'PROMOTE' : 'HOLD'));
972
969
  }
973
970
  function validateQualificationIntegrity(value) {
974
971
  if (!value || !Number.isSafeInteger(value.sample_n) || value.sample_n < 0
@@ -986,9 +983,6 @@ function validateQualificationIntegrity(value) {
986
983
  function digestEvalIntegrity(value) {
987
984
  return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`;
988
985
  }
989
- function integrityRank(decision) {
990
- return { PROMOTE: 0, HOLD: 1, ROLLBACK: 2 }[decision];
991
- }
992
986
  function activeExportHold(hold, subject, nowEpochMs) {
993
987
  return hold.subject === subject && hold.expiresAt > nowEpochMs && hold.scope.includes('export');
994
988
  }
@@ -1,3 +1,4 @@
1
+ import { ceilingDecision } from '../../gates/lattice.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { mkdir, readFile, rm } from 'node:fs/promises';
3
4
  import { join, resolve } from 'node:path';
@@ -425,7 +426,6 @@ export async function scoreHeldoutStudy(input) {
425
426
  return { schemaVersion: 'decision-heldout-score/v1', source: frozen.priorRuns.some(run => run.source === 'injected-transport') ? 'injected-transport' : frozen.source, evidenceDigest: input.trustedEvidenceDigest, integrityDigest: input.trustedIntegrityDigest,
426
427
  complete, diagnostics, approvedCalibration, calibrated: studyValidated ? true : calibrated, d09Qualified: studyValidated,
427
428
  calibratedGate: studyValidated && study?.calibratedGate === true,
428
- calibrationArtifactValidation: studyValidated ? 'performed-by-study-scorer' : 'not-performed', integrityProblems: problems, decision: input.integrity.release_gate.decision === 'ROLLBACK'
429
- || input.integrity.compromise_labels.length > 0 ? 'ROLLBACK' : 'HOLD' };
429
+ calibrationArtifactValidation: studyValidated ? 'performed-by-study-scorer' : 'not-performed', integrityProblems: problems, decision: ceilingDecision('HOLD', input.integrity.release_gate.decision, input.integrity.compromise_labels.length > 0 ? 'ROLLBACK' : 'PROMOTE') };
430
430
  }
431
431
  //# sourceMappingURL=collector.js.map
@@ -1,3 +1,4 @@
1
+ import { assertDecisionCeiling, ceilingDecision } from '../../gates/lattice.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { dirname, resolve } from 'node:path';
@@ -26,7 +27,6 @@ const SCHEMA_FILES = {
26
27
  manifest: 'DecisionIssueTriageEvaluationManifest.v1.schema.json',
27
28
  report: 'DecisionIssueTriageEvaluationReport.v1.schema.json',
28
29
  };
29
- const GATE_RANK = { PROMOTE: 0, HOLD: 1, ROLLBACK: 2 };
30
30
  const FINAL_FIELDS = ['finalLabels', 'finalDuplicateOf', 'resolution', 'closedAt'];
31
31
  const RESPONSE_FIELDS = ['issueId', 'issueType', 'area', 'urgency', 'completeness', 'clarificationNeed', 'duplicate',
32
32
  'uncertaintyProfile', 'accepted', 'actualModel', 'requestedModel', 'latencyMs', 'usage', 'usageReceipts', 'calls', 'retries', 'fallbacks',
@@ -156,9 +156,7 @@ function checkIssueTriageEvaluationReportShape(value) {
156
156
  checkSchema('report', value);
157
157
  const report = value;
158
158
  const problems = [];
159
- if (GATE_RANK[report.decision] < GATE_RANK[report.integrity.upstreamDecision]) {
160
- problems.push(`issue triage report cannot upgrade ${report.integrity.upstreamDecision} to ${report.decision}`);
161
- }
159
+ assertDecisionCeiling(report.decision, report.integrity.upstreamDecision, gateDecision(report.integrity.upstreamDecision, report.integrity.findings));
162
160
  if (report.decision === 'PROMOTE' && report.integrity.findings.length > 0)
163
161
  problems.push('PROMOTE requires no findings');
164
162
  if (report.decision === 'PROMOTE' && report.baselineComparison.qualityNonInferiority?.decision !== 'non-inferior') {
@@ -603,11 +601,7 @@ function correct(sample, which) {
603
601
  && observed.duplicate.issueId === sample.label.duplicateOf;
604
602
  }
605
603
  function gateDecision(upstream, findings) {
606
- if (upstream === 'ROLLBACK')
607
- return 'ROLLBACK';
608
- if (upstream === 'HOLD' || findings.length > 0)
609
- return 'HOLD';
610
- return 'PROMOTE';
604
+ return ceilingDecision(upstream, findings.length > 0 ? 'HOLD' : 'PROMOTE');
611
605
  }
612
606
  /** Supported interval level in bps, or null. The paired helpers accept integer levels strictly in (5000, 9999). */
613
607
  function levelInBps(confidenceInterval) {
@@ -1,3 +1,4 @@
1
+ import { ceilingDecision } from '../../gates/lattice.js';
1
2
  import { expectedQualificationCaseIds, validateCaseInventory } from './manifest.js';
2
3
  import { deriveGateEvidence, DERIVED_GATE_EVIDENCE } from './gate-evidence.js';
3
4
  // Negative evidence always overrides a positive suite result. A release reviewer
@@ -76,7 +77,7 @@ export function evaluateQualification(manifest, definitions = DECISION_RELEASE_G
76
77
  schemaVersion: 'decision-qualification-report/v1',
77
78
  runId: manifest.runId,
78
79
  generatedAt: manifest.generatedAt,
79
- decision: mandatoryFailed ? 'HOLD' : 'PROMOTE',
80
+ decision: ceilingDecision('PROMOTE', mandatoryFailed ? 'HOLD' : 'PROMOTE'),
80
81
  gates,
81
82
  };
82
83
  }
@@ -1,3 +1,4 @@
1
+ import { ceilingDecision } from '../../gates/lattice.js';
1
2
  import { canonicalSha256, matchEvidenceDigest } from '../../gates/stats/index.js';
2
3
  /**
3
4
  * #2037/#2048 integrity modes whose verified state has a trusted score source.
@@ -133,9 +134,7 @@ export function buildQualificationReleaseRecord(executed, input) {
133
134
  && suites.length === manifest.cases.length && suites.every(item => item.outcome === 'pass' && item.verified)
134
135
  && report.gates.length === 7 && report.gates.every(gate => gate.status === 'pass')
135
136
  && report.decision === 'PROMOTE';
136
- const decision = releaseDecision === 'ROLLBACK'
137
- || report.decision === 'ROLLBACK' || input.integrity.integrity_state === 'compromised' || input.integrity.compromise_labels.length > 0 ? 'ROLLBACK'
138
- : complete && releaseDecision === 'PROMOTE' ? 'PROMOTE' : 'HOLD';
137
+ const decision = ceilingDecision(releaseDecision, report.decision, complete ? 'PROMOTE' : 'HOLD', input.integrity.integrity_state === 'compromised' || input.integrity.compromise_labels.length > 0 ? 'ROLLBACK' : 'PROMOTE');
139
138
  const fields = {
140
139
  schemaVersion: 'decision-qualification-release/v2',
141
140
  runId: manifest.runId, sourceCommit: manifest.sourceCommit, dirty: manifest.dirty,
@@ -1,3 +1,4 @@
1
+ import { assertDecisionCeiling } from '../../gates/lattice.js';
1
2
  import { canonicalJson } from '../../security/artifact-trust.js';
2
3
  import { ensembleIntegrityDecision, integrityMetadataFindings } from '../ensemble/contract.js';
3
4
  import { pairedBinaryDifferenceInterval, pairedNonInferiority } from '../qualification/quality.js';
@@ -20,6 +21,7 @@ export function buildRoutingShadowReport(input) {
20
21
  export function validateRoutingShadowReport(value, trustedPreregistrationDigest) {
21
22
  checkRoutingSchema('shadowReport', value);
22
23
  const report = value;
24
+ assertDecisionCeiling(report.decision, ensembleIntegrityDecision(report.integrity, report.reasons.length));
23
25
  const rebuilt = computeRoutingShadowReport({
24
26
  id: report.id, preregistration: report.preregistration, trustedPreregistrationDigest,
25
27
  holdoutAccessedAt: report.holdoutAccessedAt, evaluatedAt: report.evaluatedAt,
@@ -1,3 +1,4 @@
1
+ import { ceilingDecision } from '../gates/lattice.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { DECISION_API_VERSION, DECISION_API_VERSION_STRUCTURED, } from './types.js';
3
4
  import { reviewDigest } from './review/validate.js';
@@ -1135,11 +1136,8 @@ export function buildSdlcScreeningReleaseReport(input) {
1135
1136
  const preregistered = evaluateSdlcScreeningPreregistration(input.preregistration, input.trustedPreregistrationDigest, input.heldout, input.nowEpochMs);
1136
1137
  const integrityProblems = qualificationIntegrityAllowlistProblems(input.integrity);
1137
1138
  const readable = !integrityProblems.includes('integrity-invalid');
1138
- const compromised = readable && (input.integrity.release_gate.decision === 'ROLLBACK'
1139
- || input.integrity.integrity_state === 'compromised' || input.integrity.compromise_labels.length > 0);
1140
- const decision = compromised ? 'ROLLBACK'
1141
- : readable && integrityProblems.length === 0 && preregistered.decision === 'pass'
1142
- && input.integrity.release_gate.decision === 'PROMOTE' ? 'PROMOTE' : 'HOLD';
1139
+ const compromised = readable && (input.integrity.integrity_state === 'compromised' || input.integrity.compromise_labels.length > 0);
1140
+ const decision = ceilingDecision(readable ? input.integrity.release_gate.decision : 'HOLD', compromised ? 'ROLLBACK' : 'PROMOTE', integrityProblems.length === 0 && preregistered.decision === 'pass' ? 'PROMOTE' : 'HOLD');
1143
1141
  const unsigned = {
1144
1142
  schemaVersion: input.preregistration.schemaVersion === SDLC_SCREENING_PREREGISTRATION_VERSION_V2
1145
1143
  ? SDLC_SCREENING_RELEASE_VERSION_V2 : SDLC_SCREENING_RELEASE_VERSION,
@@ -1,3 +1,4 @@
1
+ import { assertDecisionCeiling, ceilingDecision } from '../../gates/lattice.js';
1
2
  import { pairedBinaryDifferenceInterval, wilsonScoreInterval } from '../qualification/quality.js';
2
3
  import { qualificationIntegrityAllowlistProblems } from '../qualification/release.js';
3
4
  import { checkSensitivitySchema, sensitivityDigest, validateSensitivityReport } from './contract.js';
@@ -128,9 +129,8 @@ export function buildComparativeReplayReport(input) {
128
129
  throw new Error('operator review ingestion is not implemented');
129
130
  findings.add('reviewer-audit-pending');
130
131
  const upstreamDecision = input.integrity.release_gate.decision;
131
- const decision = upstreamDecision === 'ROLLBACK' || input.integrity.integrity_state === 'compromised'
132
- || input.integrity.compromise_labels.length > 0 ? 'ROLLBACK'
133
- : upstreamDecision === 'HOLD' || findings.size > 0 ? 'HOLD' : 'PROMOTE';
132
+ const decision = ceilingDecision(upstreamDecision, input.integrity.integrity_state === 'compromised'
133
+ || input.integrity.compromise_labels.length > 0 ? 'ROLLBACK' : 'PROMOTE', findings.size > 0 ? 'HOLD' : 'PROMOTE');
134
134
  const payload = {
135
135
  schemaVersion: 'decision-comparative-replay-report/v1', mode: 'shadow',
136
136
  semantics: 'associative-sensitivity-not-causal', actionAuthorization: 'not-authorized',
@@ -147,7 +147,9 @@ export function buildComparativeReplayReport(input) {
147
147
  }
148
148
  export function validateComparativeReplayReport(report, input) {
149
149
  checkSensitivitySchema('comparativeReport', report);
150
+ assertDecisionCeiling(report.decision, report.upstreamDecision, input.integrity.release_gate.decision);
150
151
  const expected = buildComparativeReplayReport(input);
152
+ assertDecisionCeiling(report.decision, expected.decision);
151
153
  if (sensitivityDigest(report) !== sensitivityDigest(expected))
152
154
  throw new Error('comparative replay report differs from bound inputs');
153
155
  }
@@ -1,3 +1,4 @@
1
+ import { assertGateReportDecision } from './report.js';
1
2
  import { lstatSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
@@ -159,11 +160,12 @@ export async function validateGateFile(kind, filePath, options = {}) {
159
160
  else {
160
161
  // Full report validation needs the trusted evaluation context
161
162
  // (`validateGateReport` re-derives the report byte-identically).
162
- // `validate` checks the closed schema, the kind and the self-digest,
163
+ // `validate` checks component ceilings, the closed schema, kind and self-digest,
163
164
  // so rewritten evidence with a stale digest still fails here.
164
165
  const report = validateGateDocument(document);
165
166
  if (!isRecord(document) || document.kind !== 'GateReport')
166
167
  throw new Error('expected kind GateReport');
168
+ assertGateReportDecision(report);
167
169
  const { digest, ...fields } = report;
168
170
  if (typeof digest !== 'string' || artifactDigest(fields) !== digest) {
169
171
  throw new Error('report digest does not match its content');
@@ -276,83 +278,10 @@ export function listGatePacks(options = {}) {
276
278
  return summaries.filter(entry => entry.namespace === options.namespace);
277
279
  return summaries;
278
280
  }
279
- /**
280
- * Packs referenced by one rule's `enforcedBy` frontmatter (#2830 stub; full
281
- * coverage is #2839). Scans bundled and project-local `rules/*.md` for an
282
- * `enforcedBy: [aiwg:<bundle>/<pack>#<gate>]` list naming the rule. Returns
283
- * the referenced pack ids (without gate fragments). Empty when no rule
284
- * declares `enforcedBy` — rules remain guidance until they name a check.
285
- */
281
+ /** List the distinct packs named by validated rule coverage declarations. */
286
282
  export async function packsReferencedByRule(ruleId, options = {}) {
287
- const { readdir, readFile: readRuleFile, stat } = await import('node:fs/promises');
288
- const roots = [];
289
- const root = rootDir(options);
290
- for (const sub of ['frameworks', 'addons', 'extensions']) {
291
- roots.push(path.join(root, 'agentic/code', sub));
292
- }
293
- roots.push(path.join(baseDir(options), '.aiwg'));
294
- const ruleFiles = [];
295
- const walk = async (dir, depth) => {
296
- if (depth > 5 || ruleFiles.length > 500)
297
- return;
298
- let entries;
299
- try {
300
- entries = await readdir(dir, { withFileTypes: true });
301
- }
302
- catch {
303
- return;
304
- }
305
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
306
- if (ruleFiles.length > 500)
307
- return;
308
- const full = path.join(dir, entry.name);
309
- if (entry.isDirectory()) {
310
- if (entry.name === 'node_modules' || entry.name.startsWith('.git'))
311
- continue;
312
- await walk(full, depth + 1);
313
- }
314
- else if (entry.isFile() && entry.name.endsWith('.md')) {
315
- if (!full.replace(/\\/g, '/').includes('/rules/'))
316
- continue;
317
- ruleFiles.push(full);
318
- }
319
- }
320
- };
321
- for (const dir of roots)
322
- await walk(dir, 0);
323
- const referenced = new Set();
324
- for (const file of ruleFiles) {
325
- let content;
326
- try {
327
- const st = await stat(file);
328
- if (st.size > 65_536)
329
- continue;
330
- content = await readRuleFile(file, 'utf8');
331
- }
332
- catch {
333
- continue;
334
- }
335
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
336
- if (!match)
337
- continue;
338
- const frontmatter = match[1];
339
- if (!frontmatter.includes('enforcedBy'))
340
- continue;
341
- const ruleName = path.basename(file, '.md');
342
- const dirName = path.basename(path.dirname(file));
343
- const names = new Set([ruleName, dirName === 'rules' ? null : `${dirName}/${ruleName}`, file]);
344
- if (!names.has(ruleId) && ![...names].filter(Boolean).some(name => ruleId.endsWith(String(name))))
345
- continue;
346
- for (const line of frontmatter.split('\n')) {
347
- const trimmed = line.trim().replace(/^-\s*/, '');
348
- const id = trimmed.match(/(aiwg|framework|addon|extension|project):[^\s,'"\]]+/)?.[1]
349
- ? trimmed.match(/((?:aiwg|framework|addon|extension|project):[^\s,'"\]]+)/)?.[1]
350
- : null;
351
- if (id)
352
- referenced.add(id.split('#')[0]);
353
- }
354
- }
355
- return [...referenced].sort();
283
+ const { listRuleGateCoverage } = await import('../rules/gate-coverage.js');
284
+ return [...new Set(listRuleGateCoverage(options, ruleId).flatMap(rule => rule.enforcedBy.map(reference => reference.split('#')[0])))].sort();
356
285
  }
357
286
  /** Show one pack by full id, rest-path or short pack name. */
358
287
  export function showGatePack(id, options = {}) {
@@ -1,3 +1,4 @@
1
+ import { ceilingDecision, maxOutcome } from './lattice.js';
1
2
  import { qualificationIntegrityAllowlistProblems } from '../decision/qualification/release.js';
2
3
  import { artifactDigest } from '../decision/validate.js';
3
4
  import { evaluatePredicate } from '../decision/predicates.js';
@@ -12,11 +13,7 @@ import { qualifyGateParameter } from './types.js';
12
13
  export class GateEvaluationError extends Error {
13
14
  constructor(message) { super(message); this.name = 'GateEvaluationError'; }
14
15
  }
15
- const RANK = { PROMOTE: 0, HOLD: 1, ROLLBACK: 2 };
16
- /** Shared outcome lattice. The maximum never upgrades any component. */
17
- export function maxOutcome(...outcomes) {
18
- return outcomes.reduce((worst, outcome) => (RANK[outcome] > RANK[worst] ? outcome : worst), 'PROMOTE');
19
- }
16
+ export { maxOutcome } from './lattice.js';
20
17
  /**
21
18
  * Per-gate outcome from its own status: `pass` promotes, `fail` takes the
22
19
  * gate's `onFail`, `insufficient` takes `onInsufficient` (always HOLD by load
@@ -447,11 +444,7 @@ function upstreamOutcome(upstream) {
447
444
  const compromised = problems.includes('compromised')
448
445
  || release === 'ROLLBACK'
449
446
  || upstream.metadata.integrity_state === 'compromised';
450
- if (compromised)
451
- return { ceiling: 'ROLLBACK', problems };
452
- if (problems.length || release === 'HOLD')
453
- return { ceiling: 'HOLD', problems };
454
- return { ceiling: 'PROMOTE', problems };
447
+ return { ceiling: ceilingDecision(release ?? 'HOLD', compromised ? 'ROLLBACK' : 'PROMOTE', problems.length ? 'HOLD' : 'PROMOTE'), problems };
455
448
  }
456
449
  /**
457
450
  * Pure, deterministic gate evaluation. The binding is resolved internally
@@ -685,7 +678,7 @@ export function evaluateGates(input) {
685
678
  const packOutcome = maxOutcome(...gateEvidence.map(entry => entry.outcome));
686
679
  const { ceiling: upstreamCeiling, problems: upstreamProblems } = upstreamOutcome(upstream);
687
680
  const bindingCeiling = binding.spec.ceiling ?? 'PROMOTE';
688
- const decision = maxOutcome(packOutcome, upstreamCeiling, bindingCeiling);
681
+ const decision = ceilingDecision(packOutcome, upstreamCeiling, bindingCeiling);
689
682
  const body = {
690
683
  apiVersion: binding.apiVersion,
691
684
  kind: 'GateReport',