Detailed design: close five R-series regressions from Opus 4.7 audit

Independent regression audit (Opus 4.7) verified P1×4 + P2×9 fixes were
closed but found that three of the P2 fixes had introduced new baseline
violations and two had minor errors. This commit closes all five.

R-series fixes (in system-detailed-design.md):

- R1-01 §10.2: L2 Safety source no longer names a fictional
  PermissionEngine.current_profile() method. The L2 row now describes
  the active permission profile sources (~/.air/permissions.yaml +
  project permission config) without inventing a contract method,
  honoring DD §0 "no new public contracts."

- R1-02 §5.4 Table A: agent.started projection no longer claims a
  two-step "starting → running" update within a single event commit
  (which would violate event atomicity). The row now matches
  event-registry §3: a single status (starting or running) at emission
  time, with the follow-up transition handled by WorkerManager per the
  state machine in §20.4.

- R1-03 §18.4 + §5.4 Table B: removed the non-baseline phase: intent |
  committed payload-field extension from memory.promoted and
  debug.record.created. Outbox semantics now follow the baseline model:
  owning store performs the external write first, then ingests a single
  durable completion event whose payload matches event-registry §3
  exactly. Any future intent/commit split must go through an ADR plus
  payload version bump.

- R2-01 §7.5: corrected workspace responsibility cross-reference from
  "overview §10.5" (Direct mode) to "overview §10.3" (Scheduler state
  machine, which actually covers workspace assignment and merge).

- R2-02 §3: resolved self-contradiction in the contracts file-set
  decision. The 16 files are now stated as mandatory with default
  inlining of all overview §4 symbol groups for V1.0.0 Alpha; any
  future split is explicitly out of scope until an ADR is added.

Also adds opus4.7详细设计与UML审查.md documenting the regression
findings, baseline-evidence trails, and recommended fixes that drove
these changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-01 10:06:44 +08:00
parent 543743bbc1
commit af99aed1f4
2 changed files with 452 additions and 57 deletions

View File

@@ -156,24 +156,28 @@ platform.ts → cross-platform tier enums referenced by Doctor (from cross-p
above; the binding requirement is that the exported symbol set equals the contract set. This design
keeps the code-view §3 list as canonical and treats overview §4 as the superset note.
**Frozen file-set decision (P2-09)**: For V1.0.0 Alpha, the canonical contracts package file set is
exactly the 16 files listed above (frozen by code-view §3). Overview §4 symbol groups that do not
have a matching dedicated file are merged into the existing files per the mapping below; no new
`.ts` files are added in the contracts package without an ADR.
**Frozen file-set decision (P2-09)**: For V1.0.0 Alpha, the canonical contracts package has exactly
**16 mandatory files** (frozen by code-view §3); see the list above. Overview §4 symbol groups that
do not have a matching dedicated file are merged into one of these 16 files per the mapping below.
The default for V1.0.0 Alpha is: **all overview §4 symbol groups are inlined into the 16
mandatory files** (i.e. the "default home" column).
| Overview §4 symbol group | Canonical file (code-view §3) |
| Overview §4 symbol group | Default home (mandatory, no ADR needed) |
|---|---|
| `storage.ts` symbols (TransactionManager, Repository facades) | merged into `task.ts` (TransactionManager) and per-domain files |
| `scheduler.ts` symbols (SchedulerWavePlan, SchedulerRunResult) | `task.ts` |
| `workers.ts` symbols (WorkerRole, WorkerRuntime, IPC payloads) | `ipc.ts` + `worker-result.ts` |
| `context.ts` symbols (PromptLayer, PromptLayerLoader, ContextAssembler) | `runtime.ts` (`ContextPack`) + dedicated `context.ts` only if needed (otherwise inlined in `runtime.ts`) |
| `context.ts` symbols (PromptLayer, PromptLayerLoader, ContextAssembler) | `runtime.ts` (`ContextPack` and prompt-layer types are co-located) |
| `projection.ts` symbols | `ui.ts` |
| `doctor.ts` symbols (DoctorService, DoctorRunInput/Output) | `platform.ts` (cross-platform tier types) + dedicated `doctor.ts` only if file size warrants split |
| `knowledge.ts` symbols (DebugKnowledgeStore, LearnedMemoryStore) | inlined into `artifact.ts` / dedicated file only if needed |
| `diagnostics.ts` symbols (Diagnostic, semantic_signature types) | inlined into `tool.ts` / dedicated file only if needed |
| `doctor.ts` symbols (DoctorService, DoctorRunInput/Output) | `platform.ts` (cross-platform tier + doctor types co-located) |
| `knowledge.ts` symbols (DebugKnowledgeStore, LearnedMemoryStore) | `artifact.ts` |
| `diagnostics.ts` symbols (Diagnostic, semantic_signature types) | `tool.ts` |
The barrel `index.ts` exports the full union. Any future split into additional files requires an ADR
under `docs/architecture/adr/` and a synchronized update to code-view §3.
The barrel `index.ts` exports the full union. **No new `.ts` files** are added to the contracts
package for V1.0.0 Alpha. Any future split (for example extracting a dedicated `context.ts`,
`doctor.ts`, `knowledge.ts`, or `diagnostics.ts`) is **out of scope for V1.0.0 Alpha** and requires
an ADR under `docs/architecture/adr/` plus a synchronized update to code-view §3 before it may be
introduced in a later version.
## 4. Storage and Repositories
@@ -376,7 +380,7 @@ post-commit outbox/compensation work explicit, the map is split into two tables.
| `assistant.message.started` | upsert `message_drafts` (status=streaming) |
| `assistant.message.created` | insert `messages` + delete matching `message_drafts` |
| `assistant.message.failed` | `message_drafts.status=error` or failure artifact ref |
| `agent.started` | insert `agents` row with `status='starting'` upon spawn intent, then update to `status='running'` on the same event's commit when WorkerProcess handshake has succeeded (`workers` ack); domain row carries final state at commit time |
| `agent.started` | insert `agents` row with `status = 'starting'` when emitted at spawn (handshake not yet acknowledged) or `status = 'running'` when emitted after the WorkerProcess handshake has completed; the event carries exactly one terminal-of-emission status. The follow-up `starting → running` transition (without a dedicated durable event) is recorded by `WorkerManager` updating the row directly per the state machine in §20.4 |
| `agent.completed/failed/lost/cancelled` | update `agents.status` |
| `task.created` | insert `tasks` (+ optional `task_dependencies`) |
| `task.started` | `tasks.status=running`, set started/agent/workspace; insert `task_attempts` |
@@ -414,28 +418,29 @@ post-commit outbox/compensation work explicit, the map is split into two tables.
| `memory.candidate.created` | append |
| `memory.archived` | append (mark memory inactive in session-side mirror if any) |
**Table B — Projection + post-commit outbox/compensation (cross-DB or external write):**
**Table B — Projection paired with prior owner-side external write (cross-DB):**
These events project a session-side intent row inside the same transaction, then drive a
follow-up external write via the outbox model (§18.4, runtime-semantics §6.3-§6.4). The
external write is performed by the *owning service* after EventStore.commit, never inside
`project(event, tx)`.
For these events the **external write happens first** in the owning store; the durable
session event is then ingested by the owning service to record the completed cross-store
transition. `project(event, tx)` only writes session-DB rows; the cross-DB pair is
eventually consistent (§18.4, runtime-semantics §6.3-§6.4).
| Event type | Projection step (in transaction) | Outbox step (post-commit) | Owner |
| Event type | Projection step (in session-DB transaction) | External write (already done by owner before event is ingested) | Owner |
|---|---|---|---|
| `memory.promoted` | append durable event recording promotion intent | write `rules/`, `skills/`, or `learned-memory.db` row | `LearnedMemoryStore` / `RuleStore` |
| `memory.archived` (when external mirror exists) | append durable event | mark external memory inactive | `LearnedMemoryStore` |
| `debug.record.created` | append durable session event | insert/update row in `debug-records.db` | `DebugKnowledgeStore` |
| `memory.promoted` | append durable event (event log row); domain side has no dedicated table — `MemoryRepository` is project-DB only | write `rules/`, `skills/`, or `learned-memory.db` row | `LearnedMemoryStore` / rules subsystem |
| `memory.archived` | append durable event | mark memory inactive in external store | `LearnedMemoryStore` |
| `debug.record.created` | append durable event | insert/update row in `debug-records.db` | `DebugKnowledgeStore` |
Rules:
- `project(event, tx)` itself never opens external DBs or files; it only writes
`events_session.db` rows (events, drafts, domain projections).
- After successful commit, `EventBus.publish(event)` fires; outbox-aware subscribers
(`LearnedMemoryStore`, `DebugKnowledgeStore`, ...) then perform the external write and
emit a completion event (`memory.promoted` completion artifact, `debug.record.created`
status update) per §18.4.
- On restart, recovery scans pending outbox intents in the session DB and retries
external writes (runtime-semantics §6.4).
- `project(event, tx)` never opens external DBs or files; it only writes
`events_session.db` rows (events, drafts, session domain projections).
- The owning service performs its external write **before** ingesting the durable event.
This makes the session-DB event the "we observed the external write succeeded" record.
- If the external write fails, the owning service does **not** ingest the success event.
See §18.4 for failure modes (`task.failed` carrying `AirError`, candidate re-queue).
- On restart, recovery cross-checks for upstream session rows (e.g. `memory.candidate.created`)
without a matching downstream event (`memory.promoted`) and re-queues work
(runtime-semantics §6.4).
### 5.5 EventBus
@@ -598,7 +603,7 @@ Strategies (db-schema §16, scheduler-state-machine §MERGING): `main` (no merge
GC retention follows overview §15 (active until merge/cancel; merged 7d; abandoned 3d; cleaned keeps
DB row).
**Responsibility split — Scheduler vs. WorkspaceManager** (overview §10.5, scheduler-state-machine §4/§MERGING):
**Responsibility split — Scheduler vs. WorkspaceManager** (overview §10.3, scheduler-state-machine §4/§MERGING):
| Responsibility | Owner |
|---|---|
@@ -922,7 +927,7 @@ configuration or resource loading (L0, L1, L3, L5). The remaining layers are ass
| Layer | Source | Assembled by |
|---|---|---|
| L2 Safety | `PermissionEngine.current_profile()` + `~/.air/permissions.yaml` + project permissions | `ContextAssembler` |
| L2 Safety | Active permission profile (`~/.air/permissions.yaml` + project permission config; same source the `PermissionEngine` implementation reads internally — no new contract method) | `ContextAssembler` |
| L4 Architecture | `TaskSpec.context_refs.arc_ref` → load from plan/ADR/C4 docs | `ContextAssembler` |
| L6 Evidence | `TaskSpec.context_refs.artifacts` + `EvidenceStore.list_for_task()` | `ContextAssembler` |
| L7 Conversation | `SessionStore.messages.list_by_session()` (recent N messages) | `ContextAssembler` |
@@ -1322,40 +1327,61 @@ developer log and either re-parented or archived (runtime-semantics §5).
### 18.4 Outbox / compensation for cross-DB writes
Writes to project-level DBs (`debug-records.db`, `learned-memory.db`) or external files follow the
outbox model (runtime-semantics §6.3-§6.4, overview §8.3):
outbox model (runtime-semantics §6.3-§6.4, overview §8.3). The general pattern is:
```text
1. Insert durable session event recording intent/request (e.g. memory.promoted intent payload,
debug.record.created intent payload). Domain row in session DB marks status="pending_external".
2. Commit the session-side transaction (events_session.db).
3. After commit, EventBus delivers the event to the owning service (LearnedMemoryStore /
DebugKnowledgeStore). The service performs the external DB/file operation.
4. On success, the owning service emits a durable completion event (memory.promoted completion
payload with artifact_ref, debug.record.created status="committed") that updates the
session-side row from pending_external to committed.
5. On external failure, the service emits a durable failure event; recovery retries from step 3
based on the pending intent row.
6. On restart, recovery scans pending external intents and resumes step 3.
1. Owning service (LearnedMemoryStore, DebugKnowledgeStore, ...) performs the external
DB/file write through its own transaction. Each owning store is single-writer.
2. On success: the owning service ingests a single durable session event whose payload is
already the completion form fixed by event-registry §3 (e.g. memory.promoted with
target_ref populated, debug.record.created with debug_record_id populated). The
EventStore commit makes the cross-DB pair eventually consistent.
3. On external failure: the owning service does NOT emit the success event. It either
emits a task.failed carrying the AirError (so Scheduler/Doctor can decide), or queues
a fresh upstream candidate (e.g. memory.candidate.created) for retry.
4. On restart: recovery cross-checks session-side "upstream" rows (memory.candidate.created
without a matching memory.promoted, or pending debug-task tasks) against external store
state. Stale candidates are re-queued; orphan external rows are surfaced through Doctor.
```
**`memory.promoted` two-phase semantics** (event-registry §3, runtime-semantics §6.3):
No event payload is extended with intent/commit phase markers; the existing payload
schemas in `event-registry-v1.md §3` are honored as-is. Any future change to add an
intent-phase event would require an ADR plus a new event type or payload version bump
(event-registry §2 rule 7).
The single `memory.promoted` event type carries two semantically distinct phases distinguished by
its payload:
**`memory.promoted` outbox semantics** (event-registry §3 `MemoryPromotedPayload`, runtime-semantics §6.4):
| Phase | Payload marker | Meaning | When emitted |
|---|---|---|---|
| Intent | `phase: "intent"`, no `artifact_ref`/`memory_id` yet | The system has decided to promote a candidate; external write is pending | Step 1 above, inside session-side transaction |
| Completion | `phase: "committed"`, includes `artifact_ref` / `memory_id`, `target_store` | External store now holds the promoted memory | Step 4 above, after external write succeeds |
Per `event-registry-v1.md §3`, `memory.promoted` payload is fixed to:
`{ candidate_id, target_ref, promoted_by, summary }` — there is no `phase` field
and no separate intent/completion variants. Per `runtime-semantics-v1.md §6.4`,
`memory.promoted` records the **completed** promotion (target_ref already populated).
The intent event makes the promotion durable even if the runtime crashes before the external
write. The completion event closes the outbox loop and exposes the resulting memory ID/artifact
to downstream readers. If the external write fails permanently, a `memory.candidate.created`
follow-up may re-queue the promotion, or a Doctor task surfaces it for user attention; the
intent event itself remains in the log for audit.
The outbox sequence is therefore:
`debug.record.created` follows the same two-phase pattern (intent → committed) with payload
field `phase`.
| Step | Event / action | DB |
|---|---|---|
| 1 | `memory.candidate.created` (durable, already in registry) | session DB |
| 2 | Owning service (`ExperienceMiner` / curator) approves the candidate and writes the external store (`learned-memory.db` row, `.air/shared/rules` file, or skill file) | external DB / file |
| 3 | On success, emit `memory.promoted` with `target_ref` pointing at the just-written external row/file | session DB |
| 4 | On write failure: do **not** emit `memory.promoted`. Either re-queue via a fresh `memory.candidate.created`, or emit a `task.failed` carrying the `AirError` so Scheduler / Doctor can surface it to the user |
| 5 | On restart: recovery uses session DB candidates without matching promotion events to detect work in flight (runtime-semantics §6.4) |
`memory.archived` likewise records a completed archival (no `phase` field; payload
is fixed by event-registry §3 `MemoryArchivedPayload`).
**`debug.record.created` outbox semantics** (event-registry §3 `DebugRecordCreatedPayload`, runtime-semantics §6.3):
The payload is a single fixed schema with `debug_record_id` already assigned by
the owning `DebugKnowledgeStore`. Per `runtime-semantics §6.3` the flow is:
1. Owning `DebugKnowledgeStore` performs the `debug-records.db` insert/update.
2. On success, emit `debug.record.created` referencing the new `debug_record_id`.
3. On `debug-records.db` write failure: emit a `task.failed` (or future
`debug.record.failed`, gated by ADR) carrying the `AirError`; do **not** emit
`debug.record.created`.
No `phase` payload field is introduced in either event. Any future intent/commit
split must go through an ADR + payload version bump (event-registry §2 rule 7).
### 18.5 Security invariants