ccqa 1.31.4 → 1.33.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.
package/dist/bin/ccqa.mjs CHANGED
@@ -4775,6 +4775,18 @@ const PerspectiveStatusSchema = z.object({
4775
4775
  target: z.string().min(1).optional()
4776
4776
  }).strip();
4777
4777
  /**
4778
+ * One step of a spec, transcribed verbatim for display. Exactly one shape per
4779
+ * step: an include step carries the block name it invokes (its params stay in
4780
+ * the spec — they are wiring, not procedure), an action step carries the
4781
+ * instruction and what it expects. Never authored or reworded here: like
4782
+ * `title`, this is a mechanical copy the next `ccqa perspectives` rewrites.
4783
+ */
4784
+ const PerspectiveStepSchema = z.object({
4785
+ include: z.string().min(1).optional(),
4786
+ instruction: z.string().optional(),
4787
+ expected: z.string().optional()
4788
+ }).strip();
4789
+ /**
4778
4790
  * One test case in the inventory.
4779
4791
  *
4780
4792
  * - `title` is transcribed verbatim from the spec.yaml.
@@ -4786,11 +4798,12 @@ const PerspectiveStatusSchema = z.object({
4786
4798
  * steps (the opening screen, the state the test assumes, and the setup
4787
4799
  * prerequisites such as which role logs in). Optional: a spec may not
4788
4800
  * express all of them.
4801
+ * - `steps` is the procedure itself, transcribed verbatim (never authored —
4802
+ * see PerspectiveStepSchema) so the hub UI can show a case in full without
4803
+ * a repo checkout. The spec.yaml stays the single source of truth: this
4804
+ * copy is rewritten wholesale every time the inventory is regenerated, the
4805
+ * same way `title` is.
4789
4806
  * - `note` is a human-only field. Regenerating perspectives preserves it.
4790
- *
4791
- * The detailed test procedure and expected results are deliberately NOT
4792
- * duplicated here — the spec.yaml steps are the single source of truth for
4793
- * those. The Markdown view links back to the spec instead of restating them.
4794
4807
  */
4795
4808
  const PerspectiveSpecSchema = z.object({
4796
4809
  specName: z.string().min(1),
@@ -4799,6 +4812,7 @@ const PerspectiveSpecSchema = z.object({
4799
4812
  startScreen: z.string().optional(),
4800
4813
  testCondition: z.string().optional(),
4801
4814
  preconditions: z.array(z.string().min(1)).optional(),
4815
+ steps: z.array(PerspectiveStepSchema).optional(),
4802
4816
  status: PerspectiveStatusSchema,
4803
4817
  changedAt: z.string().optional(),
4804
4818
  note: z.string().optional()
@@ -7396,7 +7410,8 @@ const SpecVerdictSchema = z.enum([
7396
7410
  "inProgress",
7397
7411
  "needsRepair",
7398
7412
  "rerunNeeded",
7399
- "verified"
7413
+ "verified",
7414
+ "manuallyVerified"
7400
7415
  ]);
7401
7416
  /**
7402
7417
  * Which hole in the deploy log made the hub assume a deploy reached this spec
@@ -7452,6 +7467,97 @@ z.object({
7452
7467
  /** Body of `DELETE /projects/:project/locks?profile=`. */
7453
7468
  const ReleaseLocksRequestSchema = z.object({ holder: z.string().min(1) });
7454
7469
  /**
7470
+ * A person's word that they checked a spec's behaviour by hand against the
7471
+ * deployed environment. It overrides the verdict, never the ledgers: the
7472
+ * drift entry that parked the spec stays open, so the repair loop keeps its
7473
+ * reason to fix the test, while the verdict stops asking a person for what a
7474
+ * person already did.
7475
+ *
7476
+ * Anchored to the deploy head at the moment it was recorded, so it lapses on
7477
+ * its own — a deploy reaching the spec, or the spec being edited, ends its
7478
+ * coverage the same way those end a run's (ADR-0010). One per spec: a new
7479
+ * attestation replaces the previous one.
7480
+ */
7481
+ const AttestationSchema = z.object({
7482
+ by: z.string().min(1),
7483
+ at: z.string(),
7484
+ note: z.string().optional(),
7485
+ deployedSha: z.string().nullable()
7486
+ });
7487
+ /** The per-(project, profile) attestation document: "feature/spec" → the standing attestation. */
7488
+ const AttestationsSchema = z.object({ specs: z.record(z.string(), AttestationSchema).default({}) });
7489
+ /**
7490
+ * Why an attestation stopped covering the spec. One reason is named even when
7491
+ * several hold, in the order the checks run (coverage, then the spec's own
7492
+ * edits, then a later red run) — enough for a reader to see what to verify
7493
+ * before attesting again.
7494
+ */
7495
+ const AttestationLapseSchema = z.enum([
7496
+ "deployReached",
7497
+ "cannotPlace",
7498
+ "specEdited",
7499
+ "newerRed"
7500
+ ]);
7501
+ /** Body of `PUT /projects/:project/attestations?profile=`. */
7502
+ const PutAttestationRequestSchema = z.object({
7503
+ spec: z.string().min(1).max(512),
7504
+ by: z.string().min(1).max(256),
7505
+ note: z.string().max(4e3).optional()
7506
+ });
7507
+ /** Body of `DELETE /projects/:project/attestations?profile=`. */
7508
+ const DeleteAttestationRequestSchema = z.object({ spec: z.string().min(1).max(512) });
7509
+ z.object({
7510
+ project: z.string(),
7511
+ profile: z.string(),
7512
+ spec: z.string(),
7513
+ attestation: AttestationSchema
7514
+ });
7515
+ z.object({
7516
+ project: z.string(),
7517
+ profile: z.string(),
7518
+ specs: z.record(z.string(), AttestationSchema)
7519
+ });
7520
+ /**
7521
+ * A person's answer to one audit finding: the spec describes the code fine,
7522
+ * and the finding is wrong. Where an attestation speaks about the product,
7523
+ * this speaks about the *audit* — so it settles the audit axis rather than
7524
+ * the verdict, and the spec goes back to being run like any other.
7525
+ *
7526
+ * Pinned to the audit run whose finding it answers. A later audit is a new
7527
+ * observation of newer code, so it produces a new run and this stops
7528
+ * applying: the machine gets to raise the finding again, and the record of
7529
+ * the last dismissal is shown beside it rather than silently suppressing it.
7530
+ * No profile — an audit finding is about the repository, not an environment
7531
+ * (ADR-0013), which is also why this is scoped per project alone.
7532
+ */
7533
+ const AuditDismissalSchema = z.object({
7534
+ by: z.string().min(1),
7535
+ at: z.string(),
7536
+ note: z.string().min(1),
7537
+ auditRunId: z.string(),
7538
+ label: DriftLabelSchema,
7539
+ headline: z.string()
7540
+ });
7541
+ /** The per-project dismissal document: "feature/spec" → the last dismissal. */
7542
+ const AuditDismissalsSchema = z.object({ specs: z.record(z.string(), AuditDismissalSchema).default({}) });
7543
+ /** Body of `PUT /projects/:project/audit-dismissals`. */
7544
+ const PutAuditDismissalRequestSchema = z.object({
7545
+ spec: z.string().min(1).max(512),
7546
+ by: z.string().min(1).max(256),
7547
+ note: z.string().min(1).max(4e3)
7548
+ });
7549
+ /** Body of `DELETE /projects/:project/audit-dismissals`. */
7550
+ const DeleteAuditDismissalRequestSchema = z.object({ spec: z.string().min(1).max(512) });
7551
+ z.object({
7552
+ project: z.string(),
7553
+ spec: z.string(),
7554
+ dismissal: AuditDismissalSchema
7555
+ });
7556
+ z.object({
7557
+ project: z.string(),
7558
+ specs: z.record(z.string(), AuditDismissalSchema)
7559
+ });
7560
+ /**
7455
7561
  * One spec's verdict, the two axes it was derived from, and the three ledger
7456
7562
  * coordinates the view shows alongside them. The coordinates are always
7457
7563
  * present (null when the spec has no such entry); the optional fields appear
@@ -7467,8 +7573,14 @@ const SpecRerunSchema = z.object({
7467
7573
  execution: ExecutionStateSchema,
7468
7574
  driftLabel: DriftLabelSchema.exclude(["UNKNOWN"]).optional(),
7469
7575
  auditAssumedReached: RerunUnknownReasonSchema.optional(),
7576
+ auditDismissed: AuditDismissalSchema.optional(),
7577
+ auditDismissalApplied: z.boolean().optional(),
7470
7578
  executionAssumedReached: RerunUnknownReasonSchema.optional(),
7471
7579
  specChangedSince: z.string().optional(),
7580
+ manual: AttestationSchema.optional(),
7581
+ manualLapsed: AttestationSchema.extend({ because: AttestationLapseSchema }).optional(),
7582
+ manualLapsedByDeploy: DeployRefSchema.nullable().optional(),
7583
+ manualLapsedReason: RerunUnknownReasonSchema.optional(),
7472
7584
  heldBy: SpecLockSchema.nullable(),
7473
7585
  lastRun: SpecLedgerEntrySchema.nullable(),
7474
7586
  lastGreen: SpecLedgerEntrySchema.nullable(),
@@ -7711,7 +7823,7 @@ async function fetchRerunReport(hubCtx, profile) {
7711
7823
  throw new RunUsageError(`--only-hub-rerun-needed: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
7712
7824
  }
7713
7825
  const parsed = RerunReportSchema.safeParse(report);
7714
- if (!parsed.success) throw new RunUsageError(`${FLAG$1}: this hub's re-run answer is not in a shape this ccqa understands — it is likely older than this CLI. Upgrade the hub, or select with --only-affected-by <ref> instead.`);
7826
+ if (!parsed.success) throw new RunUsageError(`${FLAG$1}: this hub's re-run answer is not in a shape this ccqa understands — the hub and this CLI are on different versions, and either side being newer can cause it (a newer hub may answer with verdicts this CLI does not know). Align the two, or select with --only-affected-by <ref> instead.`);
7715
7827
  report = parsed.data;
7716
7828
  if (report.deployHead === null) throw new RunUsageError(`--only-hub-rerun-needed: no deploy has been recorded for profile "${profile}" of project "${hubCtx.project}", so nothing can be compared against. Wire \`ccqa hub deploy record\` into the deploy job, or select with --only-affected-by <ref> instead.`);
7717
7829
  return {
@@ -7723,7 +7835,8 @@ const SUMMARY_ORDER$1 = rankedOrder({
7723
7835
  needsRepair: 0,
7724
7836
  rerunNeeded: 1,
7725
7837
  inProgress: 2,
7726
- verified: 3
7838
+ manuallyVerified: 3,
7839
+ verified: 4
7727
7840
  });
7728
7841
  /**
7729
7842
  * Narrow `specs` to the ones the hub says are worth running.
@@ -7733,7 +7846,9 @@ const SUMMARY_ORDER$1 = rankedOrder({
7733
7846
  * cannot vouch for, are both as uncovered as one a deploy demonstrably
7734
7847
  * invalidated (ADR-0014). `needsRepair`, `inProgress` and `verified` are never
7735
7848
  * selected: running them repairs nothing, races something already in flight,
7736
- * or repeats work that is still current.
7849
+ * or repeats work that is still current. `manuallyVerified` is never selected
7850
+ * either — the test is still the broken one the attestation stands in for,
7851
+ * and running it would only relabel a person's answer with a machine failure.
7737
7852
  */
7738
7853
  function selectSpecsNeedingRerun(specs, report) {
7739
7854
  const counts = /* @__PURE__ */ new Map();
@@ -9224,6 +9339,20 @@ function connect(opts) {
9224
9339
  error("hub token is required (--hub-token or CCQA_HUB_TOKEN)");
9225
9340
  process.exit(2);
9226
9341
  }
9342
+ /**
9343
+ * The canonical "feature/spec" key for a CLI argument. Hub records are stored
9344
+ * and looked up under exactly this form, so an alias accepted here but kept
9345
+ * verbatim would silently never match.
9346
+ */
9347
+ function requireSpecId(rawSpecId) {
9348
+ try {
9349
+ const parsed = parseSpecPath(rawSpecId);
9350
+ return `${parsed.featureName}/${parsed.specName}`;
9351
+ } catch (err) {
9352
+ error(errMessage(err));
9353
+ process.exit(2);
9354
+ }
9355
+ }
9227
9356
  function validateSessionName(name) {
9228
9357
  const parsed = SessionNameSchema.safeParse(name);
9229
9358
  if (!parsed.success) {
@@ -9518,7 +9647,53 @@ const pushCommand = new Command("push").description("Upload the report directory
9518
9647
  meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
9519
9648
  info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
9520
9649
  }));
9521
- const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand);
9650
+ const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person checked a spec's behaviour by hand against the deployed environment. The verdict answers manuallyVerified instead of asking a person for what a person already did the drift ledger is untouched, so the repair loop keeps its reason to fix the test. The attestation lapses on its own when a deploy reaches the spec or the spec is edited.").requiredOption("--profile <name>", "Environment that was checked (e.g. 'stg'). The attestation is anchored to its current deploy head.").option("--by <name>", "Who checked. Required unless --revoke.").option("--note <text>", "What was checked and how — the reader deciding whether to trust it sees this.").option("--revoke", "Withdraw the spec's attestation instead of recording one.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
9651
+ const project = resolveProject(opts);
9652
+ const hub = connect(opts);
9653
+ const specId = requireSpecId(rawSpecId);
9654
+ if (opts.revoke) {
9655
+ await hub.deleteAttestation(project, { profile: opts.profile }, specId);
9656
+ header("hub attest", `${specId} revoked`);
9657
+ return;
9658
+ }
9659
+ if (!opts.by) {
9660
+ error("--by <name> is required: an attestation is a person's word, and it needs the person");
9661
+ process.exit(2);
9662
+ }
9663
+ const res = await hub.putAttestation(project, { profile: opts.profile }, {
9664
+ spec: specId,
9665
+ by: opts.by,
9666
+ ...opts.note !== void 0 ? { note: opts.note } : {}
9667
+ });
9668
+ header("hub attest", specId);
9669
+ meta("by", res.attestation.by);
9670
+ meta("anchored to deploy", res.attestation.deployedSha ?? "(no deploy log)");
9671
+ info("the verdict answers manuallyVerified until a deploy reaches this spec or the spec is edited");
9672
+ }));
9673
+ const dismissCommand = new Command("dismiss").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person judged the spec's current audit finding wrong: the spec describes the code fine. This settles the audit axis rather than the verdict — the spec goes back to being run like any other, and the next run says whether the person was right. The dismissal is pinned to the audit run that raised the finding, so a later audit can raise it again. No --profile: a finding is about the repository, not an environment.").option("--by <name>", "Who judged it wrong. Required unless --revoke.").option("--reason <text>", "Why the finding is wrong. Required unless --revoke — this is what a mis-firing audit learns from.").option("--revoke", "Withdraw the dismissal, putting the finding back in force.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
9674
+ const project = resolveProject(opts);
9675
+ const hub = connect(opts);
9676
+ const specId = requireSpecId(rawSpecId);
9677
+ if (opts.revoke) {
9678
+ await hub.deleteAuditDismissal(project, specId);
9679
+ header("hub dismiss", `${specId} revoked`);
9680
+ return;
9681
+ }
9682
+ if (!opts.by || !opts.reason) {
9683
+ error("--by <name> and --reason <text> are both required: a dismissal is a person's correction, and it needs the person and the correction");
9684
+ process.exit(2);
9685
+ }
9686
+ const res = await hub.putAuditDismissal(project, {
9687
+ spec: specId,
9688
+ by: opts.by,
9689
+ note: opts.reason
9690
+ });
9691
+ header("hub dismiss", specId);
9692
+ meta("by", res.dismissal.by);
9693
+ meta("dismissed", `${res.dismissal.label} — ${res.dismissal.headline || "(no headline)"}`);
9694
+ info("this finding no longer holds the spec back; a later audit can raise one of its own");
9695
+ }));
9696
+ const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(costCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
9522
9697
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
9523
9698
  function isStorageStateShape(state) {
9524
9699
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -17154,6 +17329,7 @@ async function buildSkeleton(tree) {
17154
17329
  specName: s.specName,
17155
17330
  title: meta.title,
17156
17331
  summary: "",
17332
+ ...meta.steps.length > 0 ? { steps: meta.steps } : {},
17157
17333
  status,
17158
17334
  ...lastEdit ? { changedAt: lastEdit } : {}
17159
17335
  };
@@ -17227,7 +17403,8 @@ function noteKey(featureName, specName) {
17227
17403
  function readSpecMeta(specName, specYaml) {
17228
17404
  if (specYaml === null) return {
17229
17405
  title: specName,
17230
- mode: DEFAULT_SPEC_MODE
17406
+ mode: DEFAULT_SPEC_MODE,
17407
+ steps: []
17231
17408
  };
17232
17409
  try {
17233
17410
  const parsed = parse(specYaml);
@@ -17235,16 +17412,39 @@ function readSpecMeta(specName, specYaml) {
17235
17412
  const modeResult = SpecModeSchema.safeParse(parsed.mode);
17236
17413
  return {
17237
17414
  title,
17238
- mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
17415
+ mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE,
17416
+ steps: transcribeSteps(parsed.steps)
17239
17417
  };
17240
17418
  } catch {
17241
17419
  return {
17242
17420
  title: specName,
17243
- mode: DEFAULT_SPEC_MODE
17421
+ mode: DEFAULT_SPEC_MODE,
17422
+ steps: []
17244
17423
  };
17245
17424
  }
17246
17425
  }
17247
17426
  /**
17427
+ * The spec's procedure, copied verbatim for the inventory: an include step
17428
+ * keeps only the block name (its params are wiring, not procedure), an
17429
+ * action step keeps its instruction/expected text. Anything malformed is
17430
+ * skipped — the inventory never fails over one bad step, matching how the
17431
+ * rest of this sweep treats a broken spec.
17432
+ */
17433
+ function transcribeSteps(raw) {
17434
+ if (!Array.isArray(raw)) return [];
17435
+ const steps = [];
17436
+ for (const step of raw) {
17437
+ if (typeof step !== "object" || step === null) continue;
17438
+ const s = step;
17439
+ if (typeof s.include === "string" && s.include.length > 0) steps.push({ include: s.include });
17440
+ else if (typeof s.instruction === "string" && s.instruction.length > 0) steps.push({
17441
+ instruction: s.instruction,
17442
+ ...typeof s.expected === "string" && s.expected.length > 0 ? { expected: s.expected } : {}
17443
+ });
17444
+ }
17445
+ return steps;
17446
+ }
17447
+ /**
17248
17448
  * Resolve a spec's generation target for coverage derivation, from its
17249
17449
  * already-read spec.yaml. Best-effort: an unparseable spec or a target that
17250
17450
  * can't be resolved (unknown id, agent-browser-only field misuse) falls back
@@ -18346,7 +18546,7 @@ function requireKey(config) {
18346
18546
  return config.encryptionKey;
18347
18547
  }
18348
18548
  /** Validate the `:project`/`:profile` route params into a store scope. */
18349
- function requireScope(ctx) {
18549
+ function requireScope$1(ctx) {
18350
18550
  return {
18351
18551
  project: requireSafeSegment(ctx.params.project, "project"),
18352
18552
  profile: requireSafeSegment(ctx.params.profile, "profile")
@@ -18356,7 +18556,7 @@ function requireScope(ctx) {
18356
18556
  function createPutSessionHandler(config) {
18357
18557
  return async (ctx) => {
18358
18558
  const key = requireKey(config);
18359
- const scope = requireScope(ctx);
18559
+ const scope = requireScope$1(ctx);
18360
18560
  const name = requireSafeSegment(ctx.params.name, "name");
18361
18561
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
18362
18562
  const blob = encodeEncryptedBlob(encrypt(new Uint8Array(body), key));
@@ -18368,7 +18568,7 @@ function createPutSessionHandler(config) {
18368
18568
  /** GET /api/v1/projects/:project/sessions/:profile — metadata only (names + timestamps). */
18369
18569
  function createListSessionsHandler(config) {
18370
18570
  return async (ctx) => {
18371
- const scope = requireScope(ctx);
18571
+ const scope = requireScope$1(ctx);
18372
18572
  const entries = await config.store.list(scope);
18373
18573
  sendJson(ctx.res, 200, { sessions: entries.map((e) => ({
18374
18574
  name: e.name,
@@ -18385,7 +18585,7 @@ function createListSessionsHandler(config) {
18385
18585
  function createGetSessionHandler(config) {
18386
18586
  return async (ctx) => {
18387
18587
  const key = requireKey(config);
18388
- const scope = requireScope(ctx);
18588
+ const scope = requireScope$1(ctx);
18389
18589
  const name = requireSafeSegment(ctx.params.name, "name");
18390
18590
  const stored = await config.store.get(scope, name);
18391
18591
  if (!stored) throw new HttpError(404, "not_found", `session "${name}" not found for ${scope.project}/${scope.profile}`);
@@ -18396,7 +18596,7 @@ function createGetSessionHandler(config) {
18396
18596
  /** DELETE /api/v1/projects/:project/sessions/:profile/:name */
18397
18597
  function createDeleteSessionHandler(config) {
18398
18598
  return async (ctx) => {
18399
- const scope = requireScope(ctx);
18599
+ const scope = requireScope$1(ctx);
18400
18600
  const name = requireSafeSegment(ctx.params.name, "name");
18401
18601
  await config.store.delete(scope, name);
18402
18602
  ctx.res.statusCode = 204;
@@ -18407,7 +18607,7 @@ function createDeleteSessionHandler(config) {
18407
18607
  function createPutVariableHandler(config) {
18408
18608
  return async (ctx) => {
18409
18609
  const key = requireKey(config);
18410
- const scope = requireScope(ctx);
18610
+ const scope = requireScope$1(ctx);
18411
18611
  const name = requireSafeSegment(ctx.params.name, "name");
18412
18612
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
18413
18613
  const parsed = PutVariableRequestSchema.safeParse(JSON.parse(body.toString("utf8") || "{}"));
@@ -18427,7 +18627,7 @@ function createPutVariableHandler(config) {
18427
18627
  */
18428
18628
  function createListVariablesHandler(config) {
18429
18629
  return async (ctx) => {
18430
- const scope = requireScope(ctx);
18630
+ const scope = requireScope$1(ctx);
18431
18631
  const includeValues = ctx.url.searchParams.get("include") === "values";
18432
18632
  const key = includeValues ? requireKey(config) : config.encryptionKey;
18433
18633
  const entries = await config.store.list(scope);
@@ -18462,7 +18662,7 @@ function createListVariablesHandler(config) {
18462
18662
  /** DELETE /api/v1/projects/:project/variables/:profile/:name */
18463
18663
  function createDeleteVariableHandler(config) {
18464
18664
  return async (ctx) => {
18465
- const scope = requireScope(ctx);
18665
+ const scope = requireScope$1(ctx);
18466
18666
  const name = requireSafeSegment(ctx.params.name, "name");
18467
18667
  await config.store.delete(scope, name);
18468
18668
  ctx.res.statusCode = 204;
@@ -18801,7 +19001,7 @@ function createGetAuditNeedHandler(storage) {
18801
19001
  //#endregion
18802
19002
  //#region src/hub/api/handlers/locks.ts
18803
19003
  /** A spec-key list and three short strings; nothing here should approach this. */
18804
- const MAX_BODY_BYTES$3 = 1024 * 1024;
19004
+ const MAX_BODY_BYTES$5 = 1024 * 1024;
18805
19005
  /**
18806
19006
  * POST /api/v1/projects/:project/locks?profile=
18807
19007
  *
@@ -18815,7 +19015,7 @@ function createAcquireLocksHandler(storage) {
18815
19015
  return async (ctx) => {
18816
19016
  const project = requireSafeSegment(ctx.params.project, "project");
18817
19017
  const profile = requireProfileParam(ctx.url);
18818
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, AcquireLocksRequestSchema, "lock request");
19018
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, AcquireLocksRequestSchema, "lock request");
18819
19019
  let result = {
18820
19020
  granted: [],
18821
19021
  denied: []
@@ -18844,7 +19044,7 @@ function createReleaseLocksHandler(storage) {
18844
19044
  return async (ctx) => {
18845
19045
  const project = requireSafeSegment(ctx.params.project, "project");
18846
19046
  const profile = requireProfileParam(ctx.url);
18847
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, ReleaseLocksRequestSchema, "release request");
19047
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, ReleaseLocksRequestSchema, "release request");
18848
19048
  await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
18849
19049
  ctx.res.writeHead(204).end();
18850
19050
  };
@@ -18857,7 +19057,7 @@ function createReleaseLocksHandler(storage) {
18857
19057
  * 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
18858
19058
  * never answered 413 by a limit the documented bounds don't mention.
18859
19059
  */
18860
- const MAX_BODY_BYTES$2 = 8 * 1024 * 1024;
19060
+ const MAX_BODY_BYTES$4 = 8 * 1024 * 1024;
18861
19061
  function requireAckKey(ctx) {
18862
19062
  return {
18863
19063
  project: requireSafeSegment(ctx.params.project, "project"),
@@ -18884,7 +19084,7 @@ function createGetAckHandler(storage) {
18884
19084
  function createPutAckHandler(storage) {
18885
19085
  return async (ctx) => {
18886
19086
  const key = requireAckKey(ctx);
18887
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAckRequestSchema, "ack body");
19087
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, PutAckRequestSchema, "ack body");
18888
19088
  const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
18889
19089
  sendJson(ctx.res, 200, {
18890
19090
  ...key,
@@ -18893,6 +19093,157 @@ function createPutAckHandler(storage) {
18893
19093
  };
18894
19094
  }
18895
19095
  //#endregion
19096
+ //#region src/hub/api/handlers/attestations.ts
19097
+ /** Far above the largest body `PutAttestationRequestSchema`'s bounds admit. */
19098
+ const MAX_BODY_BYTES$3 = 64 * 1024;
19099
+ function requireScope(ctx) {
19100
+ return {
19101
+ project: requireSafeSegment(ctx.params.project, "project"),
19102
+ profile: requireProfileParam(ctx.url)
19103
+ };
19104
+ }
19105
+ /**
19106
+ * GET /api/v1/projects/:project/attestations?profile= — the raw document,
19107
+ * standing and lapsed alike. Whether one still covers its spec is `/rerun`'s
19108
+ * answer; this exists so a lapsed attestation can still be found and revoked.
19109
+ */
19110
+ function createGetAttestationsHandler(storage) {
19111
+ return async (ctx) => {
19112
+ const scope = requireScope(ctx);
19113
+ const doc = await storage.attestations.get(scope.project, scope.profile);
19114
+ sendJson(ctx.res, 200, {
19115
+ ...scope,
19116
+ specs: doc.specs
19117
+ });
19118
+ };
19119
+ }
19120
+ /**
19121
+ * PUT /api/v1/projects/:project/attestations?profile= — record that a person
19122
+ * checked a spec by hand. The hub stamps the time and the profile's deploy
19123
+ * head: the anchor must be what the hub knows was deployed at this moment,
19124
+ * not what the caller believes. Replaces any previous attestation for the
19125
+ * spec.
19126
+ */
19127
+ function createPutAttestationHandler(storage) {
19128
+ return async (ctx) => {
19129
+ const scope = requireScope(ctx);
19130
+ const [body, head, targets] = await Promise.all([
19131
+ readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAttestationRequestSchema, "attestation body"),
19132
+ storage.deploys.head(scope.project, scope.profile),
19133
+ requireSpecTargets(storage.perspectives, scope.project, "what can be attested")
19134
+ ]);
19135
+ if (!targets.some((target) => target.key === body.spec)) throw new HttpError(400, "unknown_spec", `spec "${body.spec}" is not in project "${scope.project}"'s perspectives document — attest with the canonical feature/spec key, or push \`ccqa perspectives\` first`);
19136
+ const attestation = {
19137
+ by: body.by,
19138
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19139
+ ...body.note !== void 0 ? { note: body.note } : {},
19140
+ deployedSha: head?.sha ?? null
19141
+ };
19142
+ await storage.attestations.update(scope.project, scope.profile, (current) => ({ specs: {
19143
+ ...current.specs,
19144
+ [body.spec]: attestation
19145
+ } }));
19146
+ sendJson(ctx.res, 200, {
19147
+ ...scope,
19148
+ spec: body.spec,
19149
+ attestation
19150
+ });
19151
+ };
19152
+ }
19153
+ /**
19154
+ * DELETE /api/v1/projects/:project/attestations?profile= — revoke a spec's
19155
+ * attestation. Deleting one that does not exist is 200 like deleting one that
19156
+ * does: the caller asked for its absence, and it is absent.
19157
+ */
19158
+ function createDeleteAttestationHandler(storage) {
19159
+ return async (ctx) => {
19160
+ const scope = requireScope(ctx);
19161
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, DeleteAttestationRequestSchema, "attestation body");
19162
+ await storage.attestations.update(scope.project, scope.profile, (current) => {
19163
+ const { [body.spec]: _, ...rest } = current.specs;
19164
+ return { specs: rest };
19165
+ });
19166
+ sendJson(ctx.res, 200, { removed: body.spec });
19167
+ };
19168
+ }
19169
+ //#endregion
19170
+ //#region src/hub/api/handlers/audit-dismissals.ts
19171
+ /** Far above the largest body `PutAuditDismissalRequestSchema`'s bounds admit. */
19172
+ const MAX_BODY_BYTES$2 = 64 * 1024;
19173
+ /**
19174
+ * GET /api/v1/projects/:project/audit-dismissals — the raw document,
19175
+ * whether or not each entry still answers the spec's current finding. No
19176
+ * `?profile=`: a finding is about the repository (see `AuditDismissalSchema`).
19177
+ */
19178
+ function createGetAuditDismissalsHandler(storage) {
19179
+ return async (ctx) => {
19180
+ const project = requireSafeSegment(ctx.params.project, "project");
19181
+ const doc = await storage.auditDismissals.get(project);
19182
+ sendJson(ctx.res, 200, {
19183
+ project,
19184
+ specs: doc.specs
19185
+ });
19186
+ };
19187
+ }
19188
+ /**
19189
+ * PUT /api/v1/projects/:project/audit-dismissals — record that a person
19190
+ * judged the spec's current audit finding wrong.
19191
+ *
19192
+ * The finding being answered is read from the ledger rather than taken from
19193
+ * the caller: a dismissal must name the run and the label it answers, and
19194
+ * only the hub knows which finding is current. A spec with no open finding is
19195
+ * a 400 — there is nothing to dismiss, and accepting it would write a record
19196
+ * that never applies to anything.
19197
+ *
19198
+ * The guard stops there on purpose. `/rerun` applies a dismissal only while
19199
+ * the audit is also *current* for the profile being asked about, and that is
19200
+ * a per-profile question this endpoint has no profile to ask it of (a finding
19201
+ * is about the repository, so the dismissal is project-scoped). A dismissal
19202
+ * written while a deploy has overtaken the audit is harmless: the next audit
19203
+ * supersedes the finding, and the record with it.
19204
+ */
19205
+ function createPutAuditDismissalHandler(storage) {
19206
+ return async (ctx) => {
19207
+ const project = requireSafeSegment(ctx.params.project, "project");
19208
+ const [body, ledger] = await Promise.all([readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAuditDismissalRequestSchema, "dismissal body"), storage.driftLedger.getMerged(project)]);
19209
+ const entry = ledger.specs[body.spec];
19210
+ if (!entry || entry.label === null) throw new HttpError(400, "no_open_finding", `spec "${body.spec}" has no open audit finding in project "${project}" — there is nothing to dismiss`);
19211
+ const dismissal = {
19212
+ by: body.by,
19213
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19214
+ note: body.note,
19215
+ auditRunId: entry.runId,
19216
+ label: entry.label,
19217
+ headline: entry.headline ?? ""
19218
+ };
19219
+ await storage.auditDismissals.update(project, (current) => ({ specs: {
19220
+ ...current.specs,
19221
+ [body.spec]: dismissal
19222
+ } }));
19223
+ sendJson(ctx.res, 200, {
19224
+ project,
19225
+ spec: body.spec,
19226
+ dismissal
19227
+ });
19228
+ };
19229
+ }
19230
+ /**
19231
+ * DELETE /api/v1/projects/:project/audit-dismissals — withdraw a dismissal,
19232
+ * putting the audit's finding back in force. Deleting one that does not exist
19233
+ * is 200: the caller asked for its absence, and it is absent.
19234
+ */
19235
+ function createDeleteAuditDismissalHandler(storage) {
19236
+ return async (ctx) => {
19237
+ const project = requireSafeSegment(ctx.params.project, "project");
19238
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAuditDismissalRequestSchema, "dismissal body");
19239
+ await storage.auditDismissals.update(project, (current) => {
19240
+ const { [body.spec]: _, ...rest } = current.specs;
19241
+ return { specs: rest };
19242
+ });
19243
+ sendJson(ctx.res, 200, { removed: body.spec });
19244
+ };
19245
+ }
19246
+ //#endregion
18896
19247
  //#region src/hub/api/handlers/spend.ts
18897
19248
  /** One entry is a handful of short fields; anything larger is a malformed client. */
18898
19249
  const MAX_BODY_BYTES$1 = 4 * 1024;
@@ -18965,7 +19316,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
18965
19316
  return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
18966
19317
  }
18967
19318
  function computeRerun(input) {
18968
- const { specs, ledger, log, touchIndex, drift, locks, now } = input;
19319
+ const { specs, ledger, log, touchIndex, drift, locks, attestations, dismissals, now } = input;
18969
19320
  const range = buildRange(log, touchIndex);
18970
19321
  const deployTimes = deployedAt(log);
18971
19322
  const out = {};
@@ -18978,16 +19329,35 @@ function computeRerun(input) {
18978
19329
  let audit = auditState(drift, spec.key, range);
18979
19330
  let execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
18980
19331
  const driftEntry = drift.specs[spec.key];
19332
+ const dismissal = dismissals.specs[spec.key];
19333
+ const dismissed = dismissal !== void 0 && driftEntry !== void 0 && dismissal.auditRunId === driftEntry.runId && dismissal.label === driftEntry.label && (audit.audit === "drifted" || audit.audit === "undecided");
19334
+ if (dismissed) audit = { audit: "clean" };
18981
19335
  const auditMoved = specMovedSince(spec.changedAt, driftEntry?.gitHead ?? null, driftEntry?.at ?? "", deployTimes);
18982
19336
  const runMoved = specMovedSince(spec.changedAt, coords.lastRun?.deployedSha ?? null, coords.lastRun?.at ?? "", deployTimes);
18983
19337
  if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
18984
19338
  if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
18985
19339
  const held = heldBy(locks, spec.key, now);
19340
+ let verdict = decide(audit.audit, execution.execution, held);
19341
+ const manualState = readAttestation(attestations.specs[spec.key], spec, coords.lastRed, range, log, deployTimes);
19342
+ if (manualState?.kind === "covers" && !held && verdict !== "verified") verdict = "manuallyVerified";
18986
19343
  out[spec.key] = {
18987
- verdict: decide(audit.audit, execution.execution, held),
19344
+ verdict,
18988
19345
  ...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
18989
19346
  ...audit,
18990
19347
  ...execution,
19348
+ ...dismissal ? {
19349
+ auditDismissed: dismissal,
19350
+ auditDismissalApplied: dismissed
19351
+ } : {},
19352
+ ...manualState?.kind === "covers" ? { manual: manualState.attest } : {},
19353
+ ...manualState?.kind === "lapsed" ? {
19354
+ manualLapsed: {
19355
+ ...manualState.attest,
19356
+ because: manualState.because
19357
+ },
19358
+ ...manualState.because === "deployReached" ? { manualLapsedByDeploy: manualState.byDeploy } : {},
19359
+ ...manualState.reason ? { manualLapsedReason: manualState.reason } : {}
19360
+ } : {},
18991
19361
  heldBy: held,
18992
19362
  ...coords
18993
19363
  };
@@ -18995,6 +19365,50 @@ function computeRerun(input) {
18995
19365
  return out;
18996
19366
  }
18997
19367
  /**
19368
+ * Does the attestation still speak for what is deployed? Checked in the order
19369
+ * the lapse enum documents: deploy coverage first (a sha the log cannot place
19370
+ * reads as reached, ADR-0014, with the hole kept as an annotation), then the
19371
+ * spec's own edits — compared against when the person looked, because they
19372
+ * read the spec as it stood that moment, which `specMovedSince` covers via a
19373
+ * null baseline sha — then a red run recorded after them, which is newer
19374
+ * information than their word. The null-sha case is the profile that had no
19375
+ * deploy log when they checked: their word covers exactly as long as that
19376
+ * stays true.
19377
+ */
19378
+ function readAttestation(attest, spec, lastRed, range, log, deployTimes) {
19379
+ if (!attest) return null;
19380
+ const coverage = attest.deployedSha === null ? log.entries.length === 0 ? { kind: "current" } : {
19381
+ kind: "unanswerable",
19382
+ reason: "unknownDeployedSha"
19383
+ } : freshness(attest.deployedSha, spec.key, range);
19384
+ if (coverage.kind === "touched") return {
19385
+ kind: "lapsed",
19386
+ attest,
19387
+ because: "deployReached",
19388
+ byDeploy: coverage.touchedByDeploy
19389
+ };
19390
+ if (coverage.kind === "unanswerable") return {
19391
+ kind: "lapsed",
19392
+ attest,
19393
+ because: "cannotPlace",
19394
+ reason: coverage.reason
19395
+ };
19396
+ if (specMovedSince(spec.changedAt, null, attest.at, deployTimes)) return {
19397
+ kind: "lapsed",
19398
+ attest,
19399
+ because: "specEdited"
19400
+ };
19401
+ if (lastRed !== null && lastRed.at > attest.at) return {
19402
+ kind: "lapsed",
19403
+ attest,
19404
+ because: "newerRed"
19405
+ };
19406
+ return {
19407
+ kind: "covers",
19408
+ attest
19409
+ };
19410
+ }
19411
+ /**
18998
19412
  * Axis 1, derived from the same freshness answer `--only-hub-audit-needed`
18999
19413
  * reads. The label only speaks once the audit is known to be current: a
19000
19414
  * verdict about an older commit says nothing about the one running now.
@@ -19098,13 +19512,15 @@ function createGetRerunHandler(storage) {
19098
19512
  return async (ctx) => {
19099
19513
  const project = requireSafeSegment(ctx.params.project, "project");
19100
19514
  const profile = requireProfileParam(ctx.url);
19101
- const [specs, ledger, log, touchIndex, drift, locks] = await Promise.all([
19515
+ const [specs, ledger, log, touchIndex, drift, locks, attestations, dismissals] = await Promise.all([
19102
19516
  requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
19103
19517
  storage.ledger.getMerged(project, profile),
19104
19518
  storage.deploys.getLog(project, profile),
19105
19519
  storage.deploys.getTouchIndex(project, profile),
19106
19520
  storage.driftLedger.getMerged(project),
19107
- storage.locks.get(project, profile)
19521
+ storage.locks.get(project, profile),
19522
+ storage.attestations.get(project, profile),
19523
+ storage.auditDismissals.get(project)
19108
19524
  ]);
19109
19525
  const head = log.entries[log.entries.length - 1];
19110
19526
  sendJson(ctx.res, 200, {
@@ -19118,6 +19534,8 @@ function createGetRerunHandler(storage) {
19118
19534
  touchIndex,
19119
19535
  drift,
19120
19536
  locks,
19537
+ attestations,
19538
+ dismissals,
19121
19539
  now: /* @__PURE__ */ new Date()
19122
19540
  })
19123
19541
  });
@@ -19842,6 +20260,7 @@ const HTML_BODY = `
19842
20260
  <button class="fchip" id="persp-chip-needsrepair" data-f="needsRepair" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.needsRepair">Needs repair</span><span class="fcount"></span></button>
19843
20261
  <button class="fchip" id="persp-chip-rerunneeded" data-f="rerunNeeded" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.rerunNeeded">Re-run needed</span><span class="fcount"></span></button>
19844
20262
  <button class="fchip" id="persp-chip-inprogress" data-f="inProgress" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.inProgress">In progress</span><span class="fcount"></span></button>
20263
+ <button class="fchip" id="persp-chip-manuallyverified" data-f="manuallyVerified" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.manuallyVerified">Manually verified</span><span class="fcount"></span></button>
19845
20264
  <button class="fchip" id="persp-chip-verified" data-f="verified" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.verified">Verified</span><span class="fcount"></span></button>
19846
20265
  </div>
19847
20266
  <div class="spacer"></div>
@@ -20567,6 +20986,9 @@ const CSS = `
20567
20986
  .sg-audit-undecided { background: var(--info); }
20568
20987
  .sg-verified, .sg-audit-clean, .sg-exec-passed { background: var(--pass); }
20569
20988
  .sg-inprogress, .sg-audit-due, .sg-exec-never { background: var(--muted-2); }
20989
+ /* A person's word, not the machine's — kept off the pass/fail palette both
20990
+ axes share so it never reads as either one. */
20991
+ .sg-manual { background: var(--violet); }
20570
20992
 
20571
20993
  .search { flex: 1; min-width: 200px; max-width: 340px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 0 10px; height: 32px; background: var(--surface); }
20572
20994
  .search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
@@ -20605,6 +21027,11 @@ const CSS = `
20605
21027
  .badge.rr-unknown .d { background: var(--info); }
20606
21028
  .badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
20607
21029
  .badge.rr-none .d { background: var(--muted); }
21030
+ /* A person's attestation, not the audit/run pipeline's own verdict — kept
21031
+ off that palette (rr-repair/rr-needed/rr-none/passed) the same way
21032
+ sg-manual is kept off the summary bar's. */
21033
+ .badge.rr-manual { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
21034
+ .badge.rr-manual .d { background: var(--violet); }
20608
21035
  .cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
20609
21036
  .graded-mark { color: var(--fg-dim); font-weight: 600; }
20610
21037
  .cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
@@ -20660,6 +21087,11 @@ const CSS = `
20660
21087
  .d-paths { display: flex; flex-wrap: wrap; gap: 6px; }
20661
21088
  .d-paths code { white-space: nowrap; }
20662
21089
  .d-prose + .d-paths { margin-top: 6px; }
21090
+ .manual-attest { margin-top: 14px; }
21091
+ .steps-box { margin-top: 14px; max-width: 900px; }
21092
+ .steps-box .slabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
21093
+ .d-steps { margin: 4px 0 0; padding-left: 18px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; color: var(--fg-dim); white-space: pre-line; }
21094
+ .d-steps .step-expected { display: block; margin-top: 2px; font-size: 12.5px; }
20663
21095
  .notebox { margin-top: 14px; max-width: 900px; }
20664
21096
  .notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
20665
21097
  .notebox textarea { width: 100%; min-height: 54px; resize: vertical; font: inherit; font-size: 13px; color: var(--fg-dim); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px; }
@@ -20667,6 +21099,12 @@ const CSS = `
20667
21099
  .notebox .nstatus { font-size: 12px; color: var(--muted); }
20668
21100
  .notebox .nstatus.ok { color: var(--pass); }
20669
21101
  .notebox .nstatus.err { color: var(--fail); }
21102
+ /* The inline form an audit-dismissal or environment-attestation button
21103
+ expands into, in place of the two window.prompt() calls this replaces. */
21104
+ .override-form { margin-top: 10px; max-width: 480px; display: flex; flex-direction: column; gap: 10px; }
21105
+ .override-form textarea { width: 100%; min-height: 54px; resize: vertical; font: inherit; font-size: 13px; color: var(--fg-dim); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px; }
21106
+ .override-form .of-act { display: flex; align-items: center; gap: 8px; }
21107
+ .override-form .of-hint { font-size: 12px; color: var(--muted); max-width: 62ch; line-height: 1.5; }
20670
21108
 
20671
21109
  @media (max-width: 900px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } .logo .wm { display: none; } .split { grid-template-columns: 1fr; } .rd-head .meta { margin-left: 0; } }
20672
21110
  @media (max-width: 700px) { .prompt-grid { grid-template-columns: 1fr; } .prompt-diff { grid-template-columns: 1fr; } }
@@ -20700,6 +21138,7 @@ const CLIENT_JS = `
20700
21138
  var THEME_KEY = "ccqa-hub-theme";
20701
21139
  var PROJECT_KEY = "ccqa-hub-project";
20702
21140
  var PROFILES_KEY = "ccqa-hub-profiles";
21141
+ var ATTEST_BY_KEY = "ccqa-attest-by";
20703
21142
 
20704
21143
  // ── i18n ──────────────────────────────────────────────────────────────
20705
21144
  // Chrome + labels only. Model output (headline/recommendation/reasoning) is
@@ -20788,6 +21227,8 @@ const CLIENT_JS = `
20788
21227
  "perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
20789
21228
  "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
20790
21229
  "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
21230
+ "perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
21231
+ "perspectives.d.stepExpected": "Expected:",
20791
21232
  "perspectives.note.label": "Note",
20792
21233
  "perspectives.note.placeholder": "Notes about this case…",
20793
21234
  "perspectives.note.saved": "Saved",
@@ -20800,6 +21241,7 @@ const CLIENT_JS = `
20800
21241
  "perspectives.rerun.state.needsRepair": "Needs repair",
20801
21242
  "perspectives.rerun.state.rerunNeeded": "Re-run needed",
20802
21243
  "perspectives.rerun.state.inProgress": "In progress",
21244
+ "perspectives.rerun.state.manuallyVerified": "Manually verified",
20803
21245
  "perspectives.rerun.state.verified": "Verified",
20804
21246
  "perspectives.rerun.vsDeploy": "judged against deploy",
20805
21247
  "perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
@@ -20835,6 +21277,29 @@ const CLIENT_JS = `
20835
21277
  "perspectives.rerun.noDeployLogBanner": "No deploy has been recorded for profile {profile}, so no case can be judged. Wire ccqa hub deploy record into the deploy job for this environment.",
20836
21278
  "perspectives.rerun.deployHead": "deploy head",
20837
21279
  "perspectives.drift.graded": "confirmed",
21280
+ "perspectives.manual.revokeButton": "Revoke manual verification",
21281
+ "perspectives.manual.envButton": "If the environment issue is resolved, use this",
21282
+ "perspectives.manual.confirmRevoke": "Revoke the manual verification for this case?",
21283
+ "perspectives.manual.error": "Could not save — retry",
21284
+ "perspectives.manual.verifiedBy": "{by} manually verified this ({at})",
21285
+ "perspectives.manual.lapsed.deployReached": "the manual verification lapsed when a deploy reached this case",
21286
+ "perspectives.manual.lapsed.deployReachedNamed": "the manual verification lapsed when deploy {sha} reached this case ({at})",
21287
+ "perspectives.manual.lapsed.cannotPlace": "the manual verification lapsed — its baseline deploy could not be placed in the log",
21288
+ "perspectives.manual.lapsed.specEdited": "the manual verification lapsed when the spec was edited",
21289
+ "perspectives.manual.lapsed.newerRed": "the manual verification lapsed after a later run failed",
21290
+ "perspectives.manual.lapsed.unrecognized": "the manual verification lapsed for a reason this UI does not recognise",
21291
+ "perspectives.dismiss.offerButton": "If the test spec was fine, use this",
21292
+ "perspectives.dismiss.revokeButton": "Undo the dismissal",
21293
+ "perspectives.dismiss.confirmRevoke": "Undo the dismissal for this case?",
21294
+ "perspectives.dismiss.activeNote": "Audit finding “{headline}” was dismissed by {by} as a false positive ({at}) — “{note}”. The next run will settle it.",
21295
+ "perspectives.dismiss.priorNote": "This finding was previously dismissed by {by} ({at}) — “{note}”.",
21296
+ "perspectives.override.byLabel": "Verified by",
21297
+ "perspectives.override.reasonLabel": "Reason (required)",
21298
+ "perspectives.override.noteLabel": "What was resolved, and how you checked (required)",
21299
+ "perspectives.override.submit": "Record",
21300
+ "perspectives.override.cancel": "Never mind",
21301
+ "perspectives.override.dismissHint": "Overrides the audit finding and closes the case. The verdict moves to “re-run needed”, and the next run settles it.",
21302
+ "perspectives.override.envHint": "The failure stays on record, but the verdict becomes “manually verified” without waiting for a re-run. It lapses once a deploy reaches this spec.",
20838
21303
  "prompt.card.record": "Recording browser actions",
20839
21304
  "prompt.card.live": "Live run (AI-driven)",
20840
21305
  "prompt.card.playwright": "Playwright test generation",
@@ -20952,6 +21417,8 @@ const CLIENT_JS = `
20952
21417
  "perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
20953
21418
  "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
20954
21419
  "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
21420
+ "perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
21421
+ "perspectives.d.stepExpected": "期待結果:",
20955
21422
  "perspectives.note.label": "note",
20956
21423
  "perspectives.note.placeholder": "このケースについてのメモ…",
20957
21424
  "perspectives.note.saved": "保存しました",
@@ -20964,6 +21431,7 @@ const CLIENT_JS = `
20964
21431
  "perspectives.rerun.state.needsRepair": "修正待ち",
20965
21432
  "perspectives.rerun.state.rerunNeeded": "要再実行",
20966
21433
  "perspectives.rerun.state.inProgress": "進行中",
21434
+ "perspectives.rerun.state.manuallyVerified": "手動確認済み",
20967
21435
  "perspectives.rerun.state.verified": "検証済み",
20968
21436
  "perspectives.rerun.vsDeploy": "判定基準: デプロイ",
20969
21437
  "perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
@@ -20999,6 +21467,29 @@ const CLIENT_JS = `
20999
21467
  "perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
21000
21468
  "perspectives.rerun.deployHead": "最新デプロイ",
21001
21469
  "perspectives.drift.graded": "人が確認",
21470
+ "perspectives.manual.revokeButton": "手動確認を取り消す",
21471
+ "perspectives.manual.envButton": "環境要因が解消した場合はこちら",
21472
+ "perspectives.manual.confirmRevoke": "このケースの手動確認を取り消しますか?",
21473
+ "perspectives.manual.error": "保存に失敗しました — 再試行してください",
21474
+ "perspectives.manual.verifiedBy": "{by}さんが手動確認({at})",
21475
+ "perspectives.manual.lapsed.deployReached": "手動確認はデプロイの到達により失効",
21476
+ "perspectives.manual.lapsed.deployReachedNamed": "手動確認はデプロイ {sha}({at})の到達により失効",
21477
+ "perspectives.manual.lapsed.cannotPlace": "手動確認は基準デプロイをログで特定できず失効",
21478
+ "perspectives.manual.lapsed.specEdited": "手動確認はspecの編集により失効",
21479
+ "perspectives.manual.lapsed.newerRed": "手動確認は直後の実行失敗により失効",
21480
+ "perspectives.manual.lapsed.unrecognized": "このUIが認識できない理由により手動確認が失効",
21481
+ "perspectives.dismiss.offerButton": "テスト仕様に問題がなかった場合はこちら",
21482
+ "perspectives.dismiss.revokeButton": "棄却を取り消す",
21483
+ "perspectives.dismiss.confirmRevoke": "このケースの棄却を取り消しますか?",
21484
+ "perspectives.dismiss.activeNote": "監査指摘「{headline}」は{by}が誤検知として棄却({at})—「{note}」。次の実行が裁定します。",
21485
+ "perspectives.dismiss.priorNote": "前回この指摘は{by}が棄却しています({at})—「{note}」",
21486
+ "perspectives.override.byLabel": "確認した人",
21487
+ "perspectives.override.reasonLabel": "理由(必須)",
21488
+ "perspectives.override.noteLabel": "解消と確認の内容(必須)",
21489
+ "perspectives.override.submit": "記録する",
21490
+ "perspectives.override.cancel": "やめる",
21491
+ "perspectives.override.dismissHint": "監査の指摘を上書きして台帳を閉じます。判定は「要再実行」に移り、次の実行が正否を裁定します。",
21492
+ "perspectives.override.envHint": "失敗の記録は残したまま、判定は再実行を待たず「手動確認済み」になります。次のデプロイがこの spec に届くと失効します。",
21002
21493
  "prompt.card.record": "ブラウザ操作の記録",
21003
21494
  "prompt.card.live": "ライブ実行(AI操作)",
21004
21495
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -21132,6 +21623,17 @@ const CLIENT_JS = `
21132
21623
  try { window.localStorage.removeItem(PROJECT_KEY); }
21133
21624
  catch (e) { console.warn("ccqa hub: could not clear stored project:", e); }
21134
21625
  }
21626
+
21627
+ // Remembered across attestations so the prompt's default is whoever last
21628
+ // used this browser, not a blank field every time.
21629
+ function loadAttestBy() {
21630
+ try { return window.localStorage.getItem(ATTEST_BY_KEY) || ""; }
21631
+ catch (e) { return ""; }
21632
+ }
21633
+ function storeAttestBy(by) {
21634
+ try { window.localStorage.setItem(ATTEST_BY_KEY, by); }
21635
+ catch (e) { /* non-fatal: next prompt just starts blank again */ }
21636
+ }
21135
21637
  function loadProfileMap() {
21136
21638
  try {
21137
21639
  var raw = window.localStorage.getItem(PROFILES_KEY);
@@ -23080,6 +23582,18 @@ const CLIENT_JS = `
23080
23582
  "/rerun?profile=" + encodeURIComponent(state.profile);
23081
23583
  }
23082
23584
 
23585
+ function attestationsPath() {
23586
+ return "/api/v1/projects/" + encodeURIComponent(state.project) +
23587
+ "/attestations?profile=" + encodeURIComponent(state.profile);
23588
+ }
23589
+
23590
+ // A person's answer to an audit finding, not an environment: the finding is
23591
+ // about the repository, not a deployed profile, so this carries no
23592
+ // ?profile= (unlike attestationsPath above).
23593
+ function auditDismissalsPath() {
23594
+ return "/api/v1/projects/" + encodeURIComponent(state.project) + "/audit-dismissals";
23595
+ }
23596
+
23083
23597
  // Resolves { report } or { note } and never rejects: a hub that predates
23084
23598
  // the endpoint costs only the columns it feeds, not the whole tab. A 404
23085
23599
  // here can only mean "no such route" — the endpoint's own 404 is "the
@@ -23158,6 +23672,37 @@ const CLIENT_JS = `
23158
23672
  return text === prefix + reason ? t(prefix + "unrecognized") : text;
23159
23673
  }
23160
23674
 
23675
+ // Who attested and when, plus their note if they left one — the whole
23676
+ // reason a "manuallyVerified" verdict has no evidence row of its own.
23677
+ function manualAttestationText(manual) {
23678
+ var text = t("perspectives.manual.verifiedBy").replace("{by}", manual.by).replace("{at}", relTime(manual.at));
23679
+ return manual.note ? text + " — " + manual.note : text;
23680
+ }
23681
+
23682
+ // An attestation that no longer covers what is deployed, named alongside
23683
+ // whatever the current verdict's own reason is (rerunCellWhy appends this)
23684
+ // rather than replacing it — the person deciding whether to attest again
23685
+ // needs both: what the axes say now, and that a person's word used to
23686
+ // stand in for it. deployReached is the only reason with a named cause
23687
+ // (manualLapsedByDeploy) to add when the log could confirm one.
23688
+ function rerunManualLapseText(rr) {
23689
+ var lapsed = rr && rr.manualLapsed;
23690
+ if (!lapsed) return null;
23691
+ if (lapsed.because === "deployReached") {
23692
+ var deploy = rr.manualLapsedByDeploy;
23693
+ return deploy
23694
+ ? t("perspectives.manual.lapsed.deployReachedNamed").replace("{sha}", shortSha(deploy.sha)).replace("{at}", relTime(deploy.at))
23695
+ : t("perspectives.manual.lapsed.deployReached");
23696
+ }
23697
+ if (lapsed.because === "cannotPlace" && rr.manualLapsedReason) {
23698
+ // The same per-hole vocabulary the axes' assumed-reached annotations
23699
+ // use — an empty deploy log and an unjudged range call for different
23700
+ // next actions, so the shrug names which hole it is.
23701
+ return t("perspectives.manual.lapsed.cannotPlace") + " (" + rerunReasonText("perspectives.rerun.why.", rr.manualLapsedReason) + ")";
23702
+ }
23703
+ return rerunReasonText("perspectives.manual.lapsed.", lapsed.because);
23704
+ }
23705
+
23161
23706
  // Every verdict that carries no evidence row explains itself here, in the
23162
23707
  // actionable phrasing the detail panel wants. decide() checks heldBy before
23163
23708
  // the audit axis, so a spec another job already holds must be explained by
@@ -23177,6 +23722,7 @@ const CLIENT_JS = `
23177
23722
  ? rerunReasonText("perspectives.rerun.fix.", rr.auditAssumedReached)
23178
23723
  : t("perspectives.rerun.inProgressHint");
23179
23724
  }
23725
+ if (rr.verdict === "manuallyVerified" && rr.manual) return manualAttestationText(rr.manual);
23180
23726
  return rerunReasonText("perspectives.rerun.fix.", rr.verdict);
23181
23727
  }
23182
23728
 
@@ -23195,16 +23741,23 @@ const CLIENT_JS = `
23195
23741
  // rather than claiming a deploy matched it.
23196
23742
  function rerunCellWhy(rr) {
23197
23743
  var head = perspState.rerun && perspState.rerun.deployHead;
23744
+ var why;
23198
23745
  if (rr.verdict === "rerunNeeded") {
23199
- if (rr.executionAssumedReached) return rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
23200
- if (!rr.touchedBy || !rr.touchedBy.length) return t("perspectives.rerun.touchedUnknown");
23201
- return t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
23202
- }
23203
- if (rr.verdict === "verified") {
23204
- if (!head) return t("perspectives.rerun.noDeployHead");
23205
- return t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
23746
+ if (rr.executionAssumedReached) why = rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
23747
+ else if (!rr.touchedBy || !rr.touchedBy.length) why = t("perspectives.rerun.touchedUnknown");
23748
+ else why = t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
23749
+ } else if (rr.verdict === "verified") {
23750
+ why = !head
23751
+ ? t("perspectives.rerun.noDeployHead")
23752
+ : t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
23753
+ } else {
23754
+ why = rerunWhyVerdict(rr);
23206
23755
  }
23207
- return rerunWhyVerdict(rr);
23756
+ // A lapsed attestation is worth saying no matter which verdict the axes
23757
+ // landed on afterwards — it is not that verdict's reason, so it is
23758
+ // appended rather than replacing it.
23759
+ var lapse = rerunManualLapseText(rr);
23760
+ return lapse ? why + " · " + lapse : why;
23208
23761
  }
23209
23762
 
23210
23763
 
@@ -23218,10 +23771,10 @@ const CLIENT_JS = `
23218
23771
  // the pipeline still owes, then what needs nothing. "needsRepair" leads
23219
23772
  // because it is the only verdict a run cannot clear — someone has to repair
23220
23773
  // the spec or the product.
23221
- var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "verified"];
23774
+ var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "manuallyVerified", "verified"];
23222
23775
  var RERUN_SEG_CLASS = {
23223
23776
  needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded",
23224
- inProgress: "sg-inprogress", verified: "sg-verified"
23777
+ inProgress: "sg-inprogress", manuallyVerified: "sg-manual", verified: "sg-verified"
23225
23778
  };
23226
23779
 
23227
23780
  // The one rule the summary bar and the verdict filter chips both answer
@@ -23235,7 +23788,7 @@ const CLIENT_JS = `
23235
23788
 
23236
23789
  // One verdict per case, bucketed, via rerunVerdictOf above.
23237
23790
  function rerunComposition(verdicts) {
23238
- var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, verified: 0 };
23791
+ var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, manuallyVerified: 0, verified: 0 };
23239
23792
  verdicts.forEach(function (rr) { counts[rerunVerdictOf(rr)] += 1; });
23240
23793
  return counts;
23241
23794
  }
@@ -23403,7 +23956,7 @@ const CLIENT_JS = `
23403
23956
  // takes the attention colour and re-running — machine work — does not.
23404
23957
  var VERDICT_BADGE = {
23405
23958
  needsRepair: "rr-repair", rerunNeeded: "rr-needed",
23406
- inProgress: "rr-none", verified: "passed"
23959
+ inProgress: "rr-none", manuallyVerified: "rr-manual", verified: "passed"
23407
23960
  };
23408
23961
 
23409
23962
  function perspVerdictCell(rr) {
@@ -23694,28 +24247,80 @@ const CLIENT_JS = `
23694
24247
  }
23695
24248
  // --- end pure: rerun detail labels ----------------------------------------
23696
24249
 
24250
+ // --- pure: audit dismissal reading ----------------------------------------
24251
+ // Self-contained (no DOM, no closures) for the same reason as the regions
24252
+ // above: read rr.auditDismissed against rr.audit, per the schema's own
24253
+ // comment on the field. "clean" means the dismissal is what is holding the
24254
+ // axis there; "drifted"/"undecided" means a later audit re-raised what it
24255
+ // answered, so the old dismissal no longer covers it.
24256
+ // The audit has something outstanding on this spec. Both values mean the
24257
+ // same thing to a reader deciding whether to answer it: the audit read the
24258
+ // code and did not clear the spec (ADR-0019).
24259
+ function auditOpen(rr) {
24260
+ return !!rr && (rr.audit === "drifted" || rr.audit === "undecided");
24261
+ }
24262
+ function auditDismissalActive(rr) {
24263
+ // The hub says whether the dismissal settled the axis. Inferring it from
24264
+ // "clean" would credit the person for a later audit clearing the spec on
24265
+ // its own, which reads identically here.
24266
+ return !!(rr && rr.auditDismissed && rr.auditDismissalApplied);
24267
+ }
24268
+ function auditDismissalReflagged(rr) {
24269
+ return !!(rr && rr.auditDismissed && auditOpen(rr));
24270
+ }
24271
+ // --- end pure: audit dismissal reading -------------------------------------
24272
+
24273
+ // The dismissal's own words, read against the current audit state: active,
24274
+ // it explains why the axis reads clean; re-flagged, it is a fact worth
24275
+ // keeping visible beside the finding that reopened it.
24276
+ function rerunDismissalLine(rr) {
24277
+ if (!rr || !rr.auditDismissed) return null;
24278
+ var d = rr.auditDismissed;
24279
+ if (auditDismissalActive(rr)) {
24280
+ return {
24281
+ muted: false,
24282
+ text: t("perspectives.dismiss.activeNote")
24283
+ .replace("{headline}", d.headline).replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
24284
+ };
24285
+ }
24286
+ if (auditDismissalReflagged(rr)) {
24287
+ return {
24288
+ muted: true,
24289
+ text: t("perspectives.dismiss.priorNote")
24290
+ .replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
24291
+ };
24292
+ }
24293
+ return null;
24294
+ }
24295
+
23697
24296
  // The evidence behind the verdict, as the value of whichever row
23698
24297
  // rerunEvidenceLabelKey chose. For needed/notNeeded that is what the deploy
23699
24298
  // log holds since this case last ran, named by rerunChangeLine.
23700
24299
  // The label already states the timeframe, so the value never repeats it.
24300
+ // A dismissal (active or superseded by a later finding) is appended below
24301
+ // whichever of those this case has, rather than replacing it — see
24302
+ // rerunDismissalLine.
23701
24303
  function rerunEvidenceValue(rr) {
23702
24304
  var wrap = el("div");
23703
24305
  if (!rerunHasEvidence(rr)) {
23704
24306
  wrap.appendChild(el("div", "d-prose", rerunWhyVerdict(rr)));
23705
- return wrap;
23706
- }
23707
- // Both states require a non-empty deploy log, so a head-less report
23708
- // contradicts itself; rerunChangeLine then names what is missing rather
23709
- // than inventing a baseline.
23710
- var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
23711
- var text = t(line.key).replace("{sha}", shortSha(line.sha));
23712
- if (line.at) text += " · " + relTime(line.at);
23713
- wrap.appendChild(el("div", "d-prose", text));
23714
- // A touch the index proved but cannot enumerate leaves no paths to list;
23715
- // the line above still says a change landed, which is all that is known.
23716
- if (rr.verdict === "rerunNeeded" && rr.touchedBy && rr.touchedBy.length) {
23717
- wrap.appendChild(pathCodes(rr.touchedBy));
24307
+ } else {
24308
+ // Both states require a non-empty deploy log, so a head-less report
24309
+ // contradicts itself; rerunChangeLine then names what is missing rather
24310
+ // than inventing a baseline.
24311
+ var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
24312
+ var text = t(line.key).replace("{sha}", shortSha(line.sha));
24313
+ if (line.at) text += " · " + relTime(line.at);
24314
+ wrap.appendChild(el("div", "d-prose", text));
24315
+ // A touch the index proved but cannot enumerate leaves no paths to
24316
+ // list; the line above still says a change landed, which is all that
24317
+ // is known.
24318
+ if (rr.verdict === "rerunNeeded" && rr.touchedBy && rr.touchedBy.length) {
24319
+ wrap.appendChild(pathCodes(rr.touchedBy));
24320
+ }
23718
24321
  }
24322
+ var dismissLine = rerunDismissalLine(rr);
24323
+ if (dismissLine) wrap.appendChild(el("div", "d-prose" + (dismissLine.muted ? " muted" : ""), dismissLine.text));
23719
24324
  return wrap;
23720
24325
  }
23721
24326
 
@@ -23734,6 +24339,154 @@ const CLIENT_JS = `
23734
24339
  return wrap;
23735
24340
  }
23736
24341
 
24342
+ // Lets a person's own check stand in for the machine's verdict. reloadRerun()
24343
+ // is the same profile-scoped refresh a profile switch uses — it re-renders
24344
+ // the whole table, so an open detail panel closes along with it.
24345
+ function submitAttestation(method, body) {
24346
+ apiFetch(attestationsPath(), {
24347
+ method: method,
24348
+ headers: { "Content-Type": "application/json" },
24349
+ body: JSON.stringify(body),
24350
+ }).then(function () { reloadRerun(); })
24351
+ .catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
24352
+ }
24353
+
24354
+ // Lets a person say an audit finding was wrong. Same reload contract as
24355
+ // submitAttestation above; a different endpoint (no ?profile=, ADR: a
24356
+ // finding is about the repository).
24357
+ function submitAuditDismissal(method, body) {
24358
+ apiFetch(auditDismissalsPath(), {
24359
+ method: method,
24360
+ headers: { "Content-Type": "application/json" },
24361
+ body: JSON.stringify(body),
24362
+ }).then(function () { reloadRerun(); })
24363
+ .catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
24364
+ }
24365
+
24366
+ function manualRevokeButton(feature, spec) {
24367
+ var btn = el("button", "btn ghost sm del", t("perspectives.manual.revokeButton"));
24368
+ btn.type = "button";
24369
+ btn.addEventListener("click", function () {
24370
+ if (!window.confirm(t("perspectives.manual.confirmRevoke"))) return;
24371
+ submitAttestation("DELETE", { spec: perspSpecKey(feature, spec) });
24372
+ });
24373
+ return btn;
24374
+ }
24375
+
24376
+ function auditDismissalRevokeButton(feature, spec) {
24377
+ var btn = el("button", "btn ghost sm del", t("perspectives.dismiss.revokeButton"));
24378
+ btn.type = "button";
24379
+ btn.addEventListener("click", function () {
24380
+ if (!window.confirm(t("perspectives.dismiss.confirmRevoke"))) return;
24381
+ submitAuditDismissal("DELETE", { spec: perspSpecKey(feature, spec) });
24382
+ });
24383
+ return btn;
24384
+ }
24385
+
24386
+ // The inline form a "dismiss" or "environment" offer button expands into,
24387
+ // in place of the two window.prompt() calls this replaces. Both kinds ask
24388
+ // for the same two things — who, and why — and differ only in wording and
24389
+ // which endpoint the answer goes to.
24390
+ function buildOverrideForm(kind, feature, spec, onCancel) {
24391
+ var wrap = el("div", "override-form");
24392
+
24393
+ var byRow = el("div", "form-row");
24394
+ byRow.appendChild(el("label", null, t("perspectives.override.byLabel")));
24395
+ var byInput = el("input", "input");
24396
+ byInput.type = "text";
24397
+ byInput.value = loadAttestBy();
24398
+ byRow.appendChild(byInput);
24399
+ wrap.appendChild(byRow);
24400
+
24401
+ var noteRow = el("div", "form-row");
24402
+ noteRow.appendChild(el("label", null, t(kind === "dismiss" ? "perspectives.override.reasonLabel" : "perspectives.override.noteLabel")));
24403
+ var noteInput = el("textarea");
24404
+ noteRow.appendChild(noteInput);
24405
+ wrap.appendChild(noteRow);
24406
+
24407
+ var act = el("div", "of-act");
24408
+ var submitBtn = el("button", "btn sm primary", t("perspectives.override.submit"));
24409
+ submitBtn.type = "button";
24410
+ submitBtn.disabled = true;
24411
+ var cancelBtn = el("button", "btn ghost sm", t("perspectives.override.cancel"));
24412
+ cancelBtn.type = "button";
24413
+ act.appendChild(submitBtn);
24414
+ act.appendChild(cancelBtn);
24415
+ wrap.appendChild(act);
24416
+
24417
+ wrap.appendChild(el("div", "of-hint", t(kind === "dismiss" ? "perspectives.override.dismissHint" : "perspectives.override.envHint")));
24418
+
24419
+ function syncEnabled() {
24420
+ submitBtn.disabled = !(byInput.value.trim() && noteInput.value.trim());
24421
+ }
24422
+ byInput.addEventListener("input", syncEnabled);
24423
+ noteInput.addEventListener("input", syncEnabled);
24424
+ cancelBtn.addEventListener("click", onCancel);
24425
+
24426
+ submitBtn.addEventListener("click", function () {
24427
+ var by = byInput.value.trim();
24428
+ var note = noteInput.value.trim();
24429
+ if (!by || !note) return;
24430
+ storeAttestBy(by);
24431
+ if (kind === "dismiss") submitAuditDismissal("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
24432
+ else submitAttestation("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
24433
+ });
24434
+
24435
+ return wrap;
24436
+ }
24437
+
24438
+ // A button that expands into buildOverrideForm above in place, rather than
24439
+ // a modal — the action is rare enough that swapping the button for its own
24440
+ // form reads fine without one.
24441
+ function buildOverrideOffer(kind, feature, spec) {
24442
+ var box = el("div", "manual-attest");
24443
+ var openBtn = el("button", "btn sm primary", t(kind === "dismiss" ? "perspectives.dismiss.offerButton" : "perspectives.manual.envButton"));
24444
+ openBtn.type = "button";
24445
+ box.appendChild(openBtn);
24446
+ openBtn.addEventListener("click", function () {
24447
+ box.removeChild(openBtn);
24448
+ var form = buildOverrideForm(kind, feature, spec, function () {
24449
+ box.removeChild(form);
24450
+ box.appendChild(openBtn);
24451
+ });
24452
+ box.appendChild(form);
24453
+ });
24454
+ return box;
24455
+ }
24456
+
24457
+ // The audit-axis override slot: dismiss an open finding, or revoke a
24458
+ // dismissal that is currently the reason the axis reads clean. At most one
24459
+ // of the two ever shows — a finding the axis
24460
+ // itself has cleared, dismissed or not, offers nothing here.
24461
+ function auditOverrideBox(feature, spec, rr) {
24462
+ if (auditDismissalActive(rr)) {
24463
+ var box = el("div", "manual-attest");
24464
+ box.appendChild(auditDismissalRevokeButton(feature, spec));
24465
+ return box;
24466
+ }
24467
+ if (auditOpen(rr)) return buildOverrideOffer("dismiss", feature, spec);
24468
+ return null;
24469
+ }
24470
+
24471
+ // The execution-axis override slot: revoke a standing attestation, or offer
24472
+ // one for an environment-caused failure — but only when the audit axis has
24473
+ // no open finding of its own, which is auditOverrideBox's problem to answer,
24474
+ // not this one's.
24475
+ function executionOverrideBox(feature, spec, rr) {
24476
+ if (rr.manual) {
24477
+ var box = el("div", "manual-attest");
24478
+ if (rr.verdict !== "manuallyVerified") box.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
24479
+ box.appendChild(manualRevokeButton(feature, spec));
24480
+ return box;
24481
+ }
24482
+ // Only once the audit has actually cleared the spec: while it is still
24483
+ // due, nobody knows yet whether the environment was the only thing wrong.
24484
+ if (rr.audit === "clean" && rr.execution === "failed" && rr.lastRed && rr.lastRed.label === "ENVIRONMENT") {
24485
+ return buildOverrideOffer("environment", feature, spec);
24486
+ }
24487
+ return null;
24488
+ }
24489
+
23737
24490
  // Detail row: a definition list of the case's fields plus the note editor.
23738
24491
  // Built with createElement/textContent throughout — every field here is
23739
24492
  // API-derived, so none of it may go through innerHTML.
@@ -23771,6 +24524,36 @@ const CLIENT_JS = `
23771
24524
  }
23772
24525
  frag.appendChild(dl);
23773
24526
 
24527
+ if (spec.steps && spec.steps.length) {
24528
+ var stepsBox = el("div", "steps-box");
24529
+ stepsBox.appendChild(el("div", "slabel", t("perspectives.d.steps")));
24530
+ var stepsList = el("ol", "d-steps");
24531
+ spec.steps.forEach(function (step) {
24532
+ var li = el("li");
24533
+ if (step.include) {
24534
+ li.textContent = t("perspectives.d.stepInclude").replace("{name}", step.include);
24535
+ } else {
24536
+ li.appendChild(document.createTextNode(step.instruction || ""));
24537
+ if (step.expected) {
24538
+ li.appendChild(el("div", "muted step-expected", t("perspectives.d.stepExpected") + " " + step.expected));
24539
+ }
24540
+ }
24541
+ stepsList.appendChild(li);
24542
+ });
24543
+ stepsBox.appendChild(stepsList);
24544
+ frag.appendChild(stepsBox);
24545
+ }
24546
+
24547
+ // A person's override, always at most one control per axis: which finding
24548
+ // is open decides whether that slot offers a new override or revokes a
24549
+ // standing one (auditOverrideBox / executionOverrideBox).
24550
+ if (rr) {
24551
+ var auditBox = auditOverrideBox(feature, spec, rr);
24552
+ if (auditBox) frag.appendChild(auditBox);
24553
+ var execBox = executionOverrideBox(feature, spec, rr);
24554
+ if (execBox) frag.appendChild(execBox);
24555
+ }
24556
+
23774
24557
  var notebox = el("div", "notebox");
23775
24558
  notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
23776
24559
  var ta = el("textarea");
@@ -25062,6 +25845,12 @@ function registerRoutes(router, config, queue) {
25062
25845
  router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
25063
25846
  router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
25064
25847
  router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
25848
+ router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
25849
+ router.put("/api/v1/projects/:project/attestations", createPutAttestationHandler(storage));
25850
+ router.delete("/api/v1/projects/:project/attestations", createDeleteAttestationHandler(storage));
25851
+ router.get("/api/v1/projects/:project/audit-dismissals", createGetAuditDismissalsHandler(storage));
25852
+ router.put("/api/v1/projects/:project/audit-dismissals", createPutAuditDismissalHandler(storage));
25853
+ router.delete("/api/v1/projects/:project/audit-dismissals", createDeleteAuditDismissalHandler(storage));
25065
25854
  router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
25066
25855
  router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
25067
25856
  router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
@@ -25351,6 +26140,12 @@ function deployTouchIndexPath(root, project, profile) {
25351
26140
  function specLocksPath(root, project, profile) {
25352
26141
  return join(root, "locks", project, profile, "locks.json");
25353
26142
  }
26143
+ function attestationsPath(root, project, profile) {
26144
+ return join(root, "attestations", project, profile, "attestations.json");
26145
+ }
26146
+ function auditDismissalsPath(root, project) {
26147
+ return join(root, "audit-dismissals", `${project}.json`);
26148
+ }
25354
26149
  function ackPath(root, project, profile, name) {
25355
26150
  return join(root, "acks", project, profile, `${name}.json`);
25356
26151
  }
@@ -25392,6 +26187,38 @@ function createFileAckStore(root) {
25392
26187
  };
25393
26188
  }
25394
26189
  //#endregion
26190
+ //#region src/hub/core/storage/file/attestation-store.ts
26191
+ function toAttestations(doc) {
26192
+ const parsed = AttestationsSchema.safeParse(doc);
26193
+ return parsed.success ? parsed.data : { specs: {} };
26194
+ }
26195
+ function createFileAttestationStore(root) {
26196
+ return {
26197
+ async get(project, profile) {
26198
+ return toAttestations(await readJson(attestationsPath(root, project, profile)));
26199
+ },
26200
+ async update(project, profile, mutate) {
26201
+ return updateJson(attestationsPath(root, project, profile), (current) => mutate(toAttestations(current)));
26202
+ }
26203
+ };
26204
+ }
26205
+ //#endregion
26206
+ //#region src/hub/core/storage/file/audit-dismissal-store.ts
26207
+ function toDismissals(doc) {
26208
+ const parsed = AuditDismissalsSchema.safeParse(doc);
26209
+ return parsed.success ? parsed.data : { specs: {} };
26210
+ }
26211
+ function createFileAuditDismissalStore(root) {
26212
+ return {
26213
+ async get(project) {
26214
+ return toDismissals(await readJson(auditDismissalsPath(root, project)));
26215
+ },
26216
+ async update(project, mutate) {
26217
+ return updateJson(auditDismissalsPath(root, project), (current) => mutate(toDismissals(current)));
26218
+ }
26219
+ };
26220
+ }
26221
+ //#endregion
25395
26222
  //#region src/hub/core/storage/file/artifact-store.ts
25396
26223
  /**
25397
26224
  * Defense-in-depth: `relPath` is expected to already be validated by the
@@ -25875,7 +26702,9 @@ function createFileHubStorage(dataDir) {
25875
26702
  deploys: createFileDeployStore(dataDir),
25876
26703
  locks: createFileLockStore(dataDir),
25877
26704
  acks: createFileAckStore(dataDir),
25878
- spend: createFileSpendStore(dataDir)
26705
+ spend: createFileSpendStore(dataDir),
26706
+ attestations: createFileAttestationStore(dataDir),
26707
+ auditDismissals: createFileAuditDismissalStore(dataDir)
25879
26708
  };
25880
26709
  }
25881
26710
  //#endregion