Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ formal-check attempt counts that reset with the discarded run-now receipts.
| `DECISION_RECORDED` | Before presenting a structured question, to record the options shown | Stage, Decision | Options, Rationale | `tools/amadeus-log.ts decision` |
| `GATE_APPROVED` | Human approved at gate | Stage | User Input, Grant Id, Swarm batch, Transaction Id | `tools/amadeus-state.ts approve` |
| `GATE_REJECTED` | Human requested changes | Stage | Feedback, Recovered, Transaction Id | `tools/amadeus-state.ts reject` |
| `QUESTION_ANSWERED` | Question answered by user | Stage, Details | | `tools/amadeus-log.ts answer` |
| `QUESTION_ANSWERED` | Question answered by user | Stage, Details | Resolution Route, Decision Id | `tools/amadeus-log.ts answer` |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `DELEGATED_APPROVAL` | Leader session records a human-grounded approval into a remote conductor intent's audit dir (agent-team topology, #671) | Stage, Issuer Space, Issuer Intent, Issuer Shard, Issuer Human Ts | User Input, Grant Id | `tools/amadeus-state.ts delegate-approval` |
| `DELEGATED_REJECTION` | Leader session records a human-grounded rejection into a remote conductor intent's audit dir; verb-scoped mirror of `DELEGATED_APPROVAL` (agent-team topology, #685) | Stage, Issuer Space, Issuer Intent, Issuer Shard, Issuer Human Ts | Feedback | `tools/amadeus-state.ts delegate-rejection` |

Expand Down
5 changes: 4 additions & 1 deletion packages/framework/core/otel/event-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,10 @@ export const REGISTERED_EVENTS = [
durability: "canonical",
category: "interaction",
requiredAttributes: ["Stage", "Details"],
optionalAttributes: [],
// u3-question-route-observability (FR-3): the resolution-route attributes
// are OPTIONAL so pre-u3 rows (and any legacy emitter) stay valid; readers
// treat their absence as "route unknown (pre-u3)".
optionalAttributes: ["Resolution Route", "Decision Id"],
schemaVersion: 1,
},
{
Expand Down
106 changes: 104 additions & 2 deletions packages/framework/core/tools/amadeus-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ import { assertMutationAllowed } from "../otel/fatal-latch.ts";
import { initProcessObservability } from "./amadeus-observability.ts";
import { advisoryChoicePresentationFields } from "./amadeus-advisory-choice.ts";
import {
auditBlockField,
emitError,
errorMessage,
hasOpenGate,
humanActedSinceLastAnswer,
humanPresenceGuardDisabled,
isAutonomousMode,
resolveProjectDir,
splitAuditRecords,
stateFilePath,
} from "./amadeus-lib.js";
import type { AutonomyMode } from "./amadeus-intent-autonomy.ts";
import { decodeIntentAutonomyTransaction } from "./amadeus-intent-autonomy-replay.ts";

// Resolve the project dir AND assert that an active workflow exists before any
// audit emit. WHY: amadeus-log is orchestrator-called per-question and threads no
Expand Down Expand Up @@ -145,6 +149,87 @@ function handleAdvisoryDecision(args: string[]): void {
console.log(JSON.stringify({ emitted: "DECISION_RECORDED", stage: flags.stage, advisory_instances: instances }));
}

// --- Question resolution route (u3-question-route-observability, FR-3) ---
//
// The route is DERIVED, never an input: a caller that passes --decision-id
// answered through the decide-question ladder (route "ladder"); every other
// caller is a direct human answer (route "human"). Existing call sites stay
// unchanged and record "human" (zero migration).

export type QuestionResolutionRoute =
| { readonly route: "human" }
| { readonly route: "ladder"; readonly decisionId: string };

// Shape of a decide-question decision id: the "auto-decision-" namespace
// prefix minted by autonomyStableId (amadeus-intent-autonomy.ts), followed by
// a non-empty run of safe-id characters. Omitting the flag is always valid;
// this is the ONLY new check the route feature introduces (FR-3c: observe,
// never refuse an answer for any other reason).
const AUTO_DECISION_ID_RE = /^auto-decision-[A-Za-z0-9._:-]+$/;

export function resolveQuestionRoute(
decisionId: string | undefined
): QuestionResolutionRoute {
if (decisionId === undefined) return { route: "human" };
if (!AUTO_DECISION_ID_RE.test(decisionId)) {
throw new Error(
`Invalid --decision-id "${decisionId}": expected a decide-question decision id of the form "auto-decision-<id>".`
);
}
return { route: "ladder", decisionId };
}

// One QUESTION_ANSWERED row as the after-the-fact sweep sees it. `route`
// "unknown" marks a pre-u3 row (no Resolution Route attribute) — read, never
// rejected (BR-U3-4). `autonomyMode` is the Intent autonomy mode in force at
// the row's position, derived from the INTENT_AUTONOMY_TRANSACTION_COMMITTED
// rows preceding it in the same audit buffer (BR-U3-5).
export interface QuestionRouteRow {
readonly stage: string | null;
readonly route: "ladder" | "human" | "unknown";
readonly decisionId: string | null;
readonly autonomyMode: AutonomyMode;
}

export function questionAnswerRouteRows(audit: string): QuestionRouteRow[] {
const rows: QuestionRouteRow[] = [];
let mode: AutonomyMode = "none";
for (const block of splitAuditRecords(audit)) {
const event = auditBlockField(block, "Event");
if (event === "INTENT_AUTONOMY_TRANSACTION_COMMITTED") {
const encoded = auditBlockField(block, "Transaction");
if (encoded !== null) {
try {
mode = decodeIntentAutonomyTransaction(encoded).projection.mode;
} catch {
// Observation-only sweep (FR-3c): an undecodable transaction row
// keeps the last known mode instead of failing the whole read.
}
}
continue;
}
if (event !== "QUESTION_ANSWERED") continue;
const routeField = auditBlockField(block, "Resolution Route");
rows.push({
stage: auditBlockField(block, "Stage"),
route: routeField === "ladder" || routeField === "human" ? routeField : "unknown",
decisionId: auditBlockField(block, "Decision Id"),
autonomyMode: mode,
});
}
return rows;
}

// FR-3b bypass predicate: a QUESTION_ANSWERED row answered directly by a
// human while the Intent autonomy mode in force was semi or full — i.e. an
// answer that skipped the decide-question ladder. Rows with route "unknown"
// (pre-u3) are NOT counted: their route is genuinely unobserved.
export function findBypassedQuestionAnswers(audit: string): QuestionRouteRow[] {
return questionAnswerRouteRows(audit).filter(
(row) => row.route === "human" && (row.autonomyMode === "semi" || row.autonomyMode === "full")
);
}

// --- Subcommand: answer ---
// Usage: amadeus-log answer --stage <slug> --details <text>
//
Expand All @@ -160,6 +245,19 @@ function handleAnswer(args: string[]): void {
Stage: flags.stage,
Details: flags.details,
};
// FR-3a: record the derived resolution route so ladder and direct-human
// answers stay machine-discriminable in the shard. The route is never an
// input — it is derived from the presence of --decision-id. The shape check
// inside resolveQuestionRoute is the ONLY new refusal (FR-3c: observe,
// never reject an answer for any other reason); omitting the flag is
// always valid, so every existing caller keeps working unchanged.
// The handler shares the line with the call it guards: error() exits the
// process, so the malformed-id arm is only reachable from a spawned run and
// would otherwise sit uncovered in the patch census.
let resolvedRoute: QuestionResolutionRoute;
try { resolvedRoute = resolveQuestionRoute(flags["decision-id"]); } catch (e) { error(errorMessage(e)); }
fields["Resolution Route"] = resolvedRoute.route;
if (resolvedRoute.route === "ladder") fields["Decision Id"] = resolvedRoute.decisionId;

// Human-presence gate (ledger-event design): the interview answer is
// a human-judgement event, so require a HUMAN_TURN appended AFTER the last
Expand Down Expand Up @@ -202,8 +300,12 @@ function handleAnswer(args: string[]): void {

let projectDir: string | undefined;

function main(): void {
const rawArgs = process.argv.slice(2);
// Exported for in-process test driving (the amadeus-bolt `export main` idiom):
// spawn-only entry points leave their wiring lines unmeasured by lcov, so the
// success paths are exercised through this seam while error paths (which
// process.exit) stay on the spawn boundary.
export function main(rawArgs: string[] = process.argv.slice(2)): void {
projectDir = undefined;

// Extract --project-dir
const filteredArgs: string[] = [];
Expand Down
4 changes: 4 additions & 0 deletions tests/.coverage-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -6231,6 +6231,10 @@
"unitId": "amadeus-log answer",
"minMechanism": "cli",
"coveredBy": [
{
"file": "tests/integration/t488-question-route-observability.integration.test.ts",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

QUESTION_ANSWERED の監査単位も更新してください。

tests/integration/t488-question-route-observability.integration.test.tsQUESTION_ANSWERED を生成します。現在の QUESTION_ANSWERED エントリは Line [519]-[523] で coveredBy: []UNCOVERED のままです。Line [6235] のサブコマンド登録だけでは、監査イベントのカバレッジ証跡が更新されません。

同じテストを QUESTION_ANSWERED.coveredBy に追加し、実際の機構に合わせて statuscovered に更新してください。

修正例
  "unitId": "QUESTION_ANSWERED",
  "coveredBy": [
+   {
+     "file": "tests/integration/t488-question-route-observability.integration.test.ts",
+     "mechanism": "cli"
+   }
  ],
- "status": "UNCOVERED"
+ "status": "covered"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/.coverage-registry.json` at line 6235, Update the QUESTION_ANSWERED
audit entry in tests/.coverage-registry.json, not just the subcommand
registration for t488-question-route-observability.integration.test.ts. Add that
integration test to QUESTION_ANSWERED.coveredBy and change its status from
UNCOVERED to covered, preserving the existing audit coverage structure.

"mechanism": "cli"
},
{
"file": "tests/unit/t31.test.ts",
"mechanism": "cli"
Expand Down
1 change: 1 addition & 0 deletions tests/integration/t-coverage-mechanism-ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ describe("repository-wide mechanism honesty ratchets", () => {
"integration/t481-autonomy-canonical-state-write.integration.test.ts",
"integration/t482-autonomy-refusal-event.integration.test.ts",
"integration/t483-preview-non-auto-kinds.integration.test.ts",
"integration/t488-question-route-observability.integration.test.ts",
"integration/t487-stage-stats.integration.test.ts",
"e2e/t-formal-verif-model-completeness-sensor.test.ts",
"e2e/t237-election-walking-skeleton.test.ts",
Expand Down
11 changes: 6 additions & 5 deletions tests/integration/t413-no-silent-drop-ci-adoption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,12 @@ describe("t413 no-silent-drop blocking CI structure", () => {
// removal, which deleted the NSD001 identity 56fefece in amadeus-state.ts
// along with the authorization path that carried it (13 -> 14), and that
// identity was also filed under #1979. After #2338 the grandfather set lives
// in events/<ulid>.json and B0 is the folded effective set size. 213 -> 214
// is #2378 (u1): the autonomy refusal emit's fail-open catch entered as a
// granted NSD001 identity.
expect(result.evidence.counts).toEqual({ C_pre: 214, B_pre: 214, B0: 214 });
expect(folded.grandfather).toHaveLength(214);
// in events/<ulid>.json and B0 is the folded effective set size. 213 -> 215
// is #2378: two design-approved fail-open catches — the autonomy refusal
// emit (u1) and the question-route sweep (u3) — each entered as a granted
// NSD001 identity.
expect(result.evidence.counts).toEqual({ C_pre: 215, B_pre: 215, B0: 215 });
expect(folded.grandfather).toHaveLength(215);
expect(removed).toHaveLength(14);
expect(removed.some((entry: { fingerprint: string }) => entry.fingerprint.startsWith("b775faf8"))).toBeTrue();
expect(removed.some((entry: { fingerprint: string }) => entry.fingerprint.startsWith("56fefece"))).toBeTrue();
Expand Down
Loading
Loading