Compare commits

...
Author SHA1 Message Date
fred e4d5fa54b4 db: agent enrollment schema (M4-4a, migration 0021)
ci/woodpecker/pr/ci Pipeline was successful
Additive-only DDL per the merged design (docs/plans/
2026-08-29-agent-enrollment-command-design.md SS4): agents gains
nullable harness + enrolled_at (no backfill); new agent_audit_events
(append-only, seq identity, FK-free agent linkage, causation self-FK,
closed event-type CHECK), agent_outbox (hierarchy shape, one row per
event, closed status enum), agent_idempotency_fence (contract 3 SS4.3
shape: UNIQUE (operation, key), replay-mode CHECK, FK-free outcome
reference).

Schema-level witnesses in agent-enrollment.witness.test.ts (PGlite +
real-PostgreSQL legs): nullable enrollment columns, event-type CHECK,
per-agent seq ordering, deletion-safe linkage (events and fence rows
survive agent deletion; information_schema FK probes), causation and
outbox RESTRICT, fence uniqueness and mode CHECK. Command-level
witnesses land with M4-4b.
2026-08-29 20:25:38 -05:00
fred ee815a72b1 docs: agent enrollment command family v1 design (M4-4-0) (#1481)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-30 01:21:00 +00:00
marcieandorch-01 94d626dff9 mosaic comms: socket resolution is tool-owned (B2) (#1476)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-29 23:35:37 +00:00
marcieandorch-01 ee6c842918 R3: --body-file <path>/- across body/comment carriers (#1474)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 22:49:20 +00:00
marcieandorch-01 ba3b854d50 D1/D3: --number canonical on issue wrappers, --labels alias on list wrappers (#1475)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 22:29:16 +00:00
fred c7a7fd07cc brain/gateway: prohibit mission_tasks.status as a write source (M4-3a phase 1) (#1479)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 21:59:13 +00:00
fred 5399c6b7e7 docs: P0 field-map currency verification at next@abb0c936 (M4-3a-0) (#1478)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 21:17:05 +00:00
marcieandorch-01 e09b8783b4 P1b: read-only viewers join the R1/R4 usage contract (#1472)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 21:03:07 +00:00
marcieandorch-01 abb0c93601 framework tools/tmux: agent-send socket default resolution + ambiguity guard (B1) (#1466)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 20:25:50 +00:00
marcieandorch-01 e67cced273 mosaic fleet logins: per-seat credential pass-through (P4 gap closure) (#1473)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 20:05:23 +00:00
fred 635cb1f666 docs: contract 2 Amendment 1 — company-CRUD capability (S2 follow-up) (#1477)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 19:34:48 +00:00
43 changed files with 8149 additions and 38 deletions
@@ -108,11 +108,13 @@ export class MissionsController {
) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
// dto.status is deliberately not forwarded: mission_tasks.status is
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
// the repo strips it as well.
return this.brain.missionTasks.create({
missionId,
taskId: dto.taskId,
userId: user.id,
status: dto.status,
description: dto.description,
notes: dto.notes,
pr: dto.pr,
+12
View File
@@ -77,6 +77,12 @@ export class CreateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@@ -102,6 +108,12 @@ export class UpdateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
+1
View File
@@ -14,6 +14,7 @@
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
@@ -0,0 +1,117 @@
---
kind: verification
status: active
---
# P0 Field-Map Currency Verification — 2026-08-29
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
integration trunk `next`. This document re-verifies the map's currency at the
lane-opening head and records the measured pre-expand writer inventory. It amends
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
wins.
## 1. Currency verification (measured)
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
2026-08-29).
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
**300 insertions, 0 deletions** — no existing declaration changed.
- The additions: the new declarations `logicalAgentConnectorLeases`,
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
column on the unmapped BetterAuth `accounts` table (shipped as
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
source.
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
therefore current at `origin/main` itself, and at the trunk head beyond it.
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
`agents`, fleet `backlog` — is byte-identical to the declaration the map
inspected. The field map is current as written. No row changes.
## 2. Refinements available since the pin (context, not map changes)
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
layer; its "bootstrap workspace" backfill step now has a shipped target:
`workspaces` (uuid PK, chained under platform projects per
`docs/requirements/hierarchy-schema.md`; hierarchy core in
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
`0019_volatile_killraven.sql`, visibility in
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
task lane's `task_events`/`task_outbox` mirror the pattern but are
workspace-scoped with the composite `(workspace_id, id)` key required by
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
shared store for task events.
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
the trunk head pinned above, so no reinterpretation of the acceptance text
is needed.
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
ownership to the mission seat `coder2`. Seat identity is operational fleet
state, not resolvable from this repository, and is outside this document's
scope. The invariant §6 protects binds regardless of seat and is restated
here as binding on the M4-3 schema lane: exactly one lane generates
migrations at a time; expand is additive; no drop/rename/narrow; constraints
validate before NOT NULL.
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
`mission_tasks.status` as a write source, plus a writer inventory, before any
expand DDL.
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
The same module also exposes `remove`/`removeByMission` DELETE paths —
immaterial to `status` writes, listed for inventory completeness.
- **Storage-layer surfaces that touch the column without authoring it**
(added 2026-08-29 after independent review of the phase-1 patch):
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
between storage tiers and must preserve the stored `status` verbatim — row
transport, exempt from the write prohibition (stripping there would corrupt
data inside the N-1 window). The generic table-keyed storage adapters
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
their table maps but have no caller that targets it: measured at this head,
every runtime adapter caller passes a fixed collection constant
(preferences/insights). Neither surface authors a new `status` value.
- **Read-only consumers of `mission_tasks`:** federation verb services
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
`brain_update_task` write the separately mapped `tasks` table, a legitimate
N-1 writer through the compatibility window.
- The ratified contract 5 decision
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
the legacy endpoints — including MCP `brain_*` task mutations — for new
consumers, while existing consumers keep working until each surface's owning
contract retires it. It does not stop existing writes.
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
on `mission_tasks` create/update) MUST land before the expand DDL of migration
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
status is ignored; the column stays declared and readable through the whole
N-1 window; retirement only after no readers).
## 4. Lane opening
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
map (§5.4), and required migration tests (§5.5) bind as written. External
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
@@ -0,0 +1,315 @@
---
kind: spec
status: active
audience: developer
---
# Agent Enrollment Command Family — v1 Design (M4-4-0)
Status: design note (implementation-facing; amends no contract).
Authority chain: tool-gateway-mapping.md §3.1 rank-4 row + §4 envelope
(ruled 2026-08-27), onboarding-wizard.md §3.5 (D11 minimal enrollment),
custody-schema.md §5.2 at revision 13 (agent-grantee FK bound to the
live `agents` table — a binding introduced at rev 4 and standing
verbatim), PRD §9 D11. Where this note and a ratified contract disagree,
the contract wins.
## 1. What the contracts bind (and what they leave open)
There is no standalone enrollment contract. The rank-4 family is defined
by composition:
1. **Contract 5 §3.1 rank 4:** "Enroll one agent: harness, credential
reference/API-key intake (values never echoed), name/persona,
assignment scope (contract 3 §3.5)."
2. **Contract 5 §4 — all five sub-clauses:** §4.1 typed request/result
DTOs validated at the Gateway boundary (expected-version only where
an owning contract defines one); §4.2 closed per-family error enum
(validation, authentication, authorization, not-found, conflict,
precondition, internal) with HTTP mappings; §4.3 audit linkage — the
envelope contributes correlation: every request accepts/generates a
correlation id, carried into the audit events **and returned in the
result**, with no second audit stream; §4.4 fail-closed — an
operation that cannot evaluate its authorization or reach its owning
tool refuses, never degrading to a fallback read or direct data
access; §4.5 CLI parity — the family MUST be invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts (a Gateway command without CLI
exposure is a tracked conformance gap).
**Idempotency keys are NOT contract 5 §4.3:** the idempotency-key
envelope is contract 3 §4.3, ratified as a drafting addition to
contract 5 §4's command envelope via contract 3 §7 item 4. Its fence
and replay rules bind as written there; §3.1 rule 5 below designs to
them.
3. **Contract 3 §3.5:** the wizard's enrollment step is minimal (one
harness, API-key login, agent name and persona — D11), uses ONLY this
family, and is skippable. Wizard witness §6.10: a run that skips the
step produces zero enrollment-family mutations.
4. **Custody-schema §5.2 (rev 13; binding introduced at rev 4):**
contract 7's agent-grantee FK references the live `agents` table
(`agents.id`, uuid); an enrollment surface with its own table would
force a contract-7 amendment.
**Assignment scope (open point, pinned here):** the rank-4 row cites
contract 3 §3.5, which defines no assignment semantics; the PRD's full
enrollment vision (Part I, Standalone flow) includes "account
assignment", but the D11 v1 slice is exactly "one harness, API key,
name/persona". v1 therefore scopes assignment to the two bindings the
minimal slice already implies — the enrolling user becomes the agent's
owner (`agents.owner_id`), and the credential reference names which of
that user's stored provider credentials the agent uses. Richer
assignment (multi-account, comms auto-enroll, workspace placement) is
deferred with the rest of the PRD's full flow (D11); when a contract
defines it, this family extends by ordinary amendment of the design.
The deferral rests on contract 3 §3.5's explicit delegation of
enrollment specifics to this family — not on reading the D11 list as
exhaustive (it is not: the §3.1 `model`/`provider` fields are required
by the live table's NOT NULL columns, though D11 does not name them).
## 2. Current state (measured 2026-08-29 at `origin/next` = `94d626df`)
- `agents` table (packages/db `schema.ts`): id uuid PK, name, provider,
model, status enum, project_id (legacy `projects`, ON DELETE SET
NULL), owner_id → users, system_prompt, allowed_tools, skills,
is_system, config jsonb, timestamps. No harness column (provider and
model describe the LLM backend, not the harness), no audit coupling.
- Sole write path: `packages/brain/src/agents.ts` repository (the only
module issuing `insert(agents)`), with three write consumers: the
legacy `/api/agents` CRUD controller
(`apps/gateway/src/agent/agent-configs.controller.ts`), the `/agent
new` chat command (`apps/gateway/src/commands/command-executor.service.ts`
`brain.agents.create`), and workspace bootstrap
(`apps/gateway/src/workspace/project-bootstrap.service.ts`). All
three keep serving existing consumers; none is touched by M4-4.
- Sealed credential store exists: `ProviderCredentialsService`
(apps/gateway/src/agent/) — one row per (userId, provider), values
sealed at rest, decrypt server-side only, summaries never carry
values.
- Harness registry exists (`apps/gateway/src/harness/`), the validation
source for the harness field.
- Implementation pattern: the merged hierarchy module (M4-1) —
transaction-scoped command context, in-tx authorization, discriminated
result unions, same-transaction semantic audit event + transactional
outbox, no-oracle not_found folding.
**F1 — contract-5 mapping note (disposition, not an amendment):**
`/api/agents` appears nowhere in contract 5 — neither as a P0 row nor in
the §3.2 legacy non-substitutes list (the ruled §3.2 freeze names
specific endpoints, and `/api/agents` is not among them). The operative
constraints are §3.3's amendment-only rule for new mapping rows and §5's
closure rule: this design adds no new consumer to `/api/agents` and
builds the rank-4 family as the P1 path for enrollment. Adding the
missing P0 row is a contract amendment for a future S2 pass; nothing in
M4-4 depends on it.
## 3. Command family surface (v1)
One command, one query. Module: `apps/gateway/src/enrollment/`
(`enrollment.module.ts`), mirroring the hierarchy module's shape.
### 3.1 `agent.enroll` (mutation)
Request DTO (shared types package, class-validator at the boundary):
| Field | Type | Rule |
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness` | string | syntactically invalid (empty/malformed) → `validation_failed`; well-formed but not in the harness registry → `precondition_failed` |
| `correlationId` | string (uuid) | optional; generated when absent (contract 5 §4.3); carried into audit events and returned in the result |
| `replayMode` | 'actor-bound' | optional, default `actor-bound`. `shared` is seed-only (contract 3 §4.3 binds it to the §3.4 canonical seed key set and "no other operation can carry a shared declaration"; §7 item 4 closes it); a `shared` declaration here is refused `validation_failed`, executes nothing, and records no fence row |
| `name` | string | non-empty, trimmed, ≤ 200 chars |
| `persona` | string \| null | optional; stored as the agent's system prompt |
| `model` | string | non-empty (provider-qualified model id) |
| `provider` | string | non-empty; names the credential's provider |
| `credential` | discriminated union | `{ mode: 'reference' }` — a credential for (actor, provider) MUST already exist; `{ mode: 'intake', type: 'api_key', value: string }` — value is sealed into the credential store in the same flow |
| `idempotencyKey` | string (uuid) | required (contract 3 §4.3, ratified into contract 5 §4 via contract 3 §7 item 4) |
Rules:
1. **Never echoed.** The credential value appears in no result DTO, no
audit event, no outbox payload, and no log line. The result carries
only `{ provider, credentialMode }`.
2. **Intake = the existing sealed store, inside the transaction.**
`intake` writes through the sealed-store path
(`ProviderCredentialsService.store` semantics: seal-at-rest, upsert
per (userId, provider)) **in the same transaction** as the agent
insert — a failure after the credential write rolls everything back,
leaving no orphan credential. Enrollment persists no second copy and
no plaintext.
3. **Reference must resolve.** `reference` with no stored credential for
(actor, provider) refuses with `precondition_failed` (nothing is
created).
4. **Ownership.** `owner_id` = the authenticated actor. v1 authorization
is AuthGuard-authenticated user; no hierarchy grant is required
because v1 enrollment binds no hierarchy node (§1 assignment-scope
pin). `is_system` is never settable through this command.
5. **Idempotency fence (contract 3 §4.3, in full).** The command layer
records, in a uniqueness-constrained fence table in the same
transaction as the mutation and its audit event: the key, the
operation identifier (`agent.enroll`), the acting principal, the
authorization scope, a digest of the canonicalized request payload
(the digest input EXCLUDES the credential value — it covers
provider + credentialMode, never plaintext), the declared replay
mode (always `actor-bound` for this family — the `shared` refusal
in the table above means no shared fence row can exist here; the
column is kept for envelope-shape fidelity and mode-mismatch
collision checks), and a reference to the committed outcome (the
agent id). The recorded **authorization scope** for this family is
pinned to the acting principal's platform-user scope (v1
authorization is grant-free per rule 4, so the scope is the
authenticated-user identity domain — recorded so the §4.3
scope-equality check has a defined value). Fence uniqueness is the
pair (operation identifier, key). **Replay:** a submission whose
(operation, key) is recorded is first authorized exactly as a fresh
submission; then replay-mode, scope, and digest equality are
checked (a mismatch on any — including scope — is a collision);
then **target-result authorization** — the submitter must hold, at
replay time, read authority on the referenced agent row under
§3.2's rule (owner or admin) — plus recorded-actor equality
(`actor-bound`). A passing replay executes nothing, returns the
recorded outcome, and appends a replay access event (non-mutation
audit class: accessing principal, current correlation id,
fence-row reference). Any equality or authorization failure refuses
with the single bounded `conflict` shape — constant, identifying no
record — preserving the no-existence-oracle rule. **Concurrency
(contract 3 §4.3's rule, ratified via §7 item 4):** two submissions
with the same (operation, key) serialize on the fence's unique
constraint — exactly one executes; the loser waits for the winner's
transaction, and is then handled as a replay if it committed
(through the full replay path above) or executes afresh if it
aborted. A unique-violation race never surfaces as an unhandled
internal fault.
6. **Audit + outbox, same transaction.** Insert into `agents` +
sealed credential write (intake mode) + fence row + semantic audit
event (`agent.enrolled`: actor, agent id, harness, provider, name,
credentialMode — no credential material) + outbox row commit
atomically, hierarchy-pattern style. Audit rows reference the agent
by **snapshot id, not FK** — mirroring the hierarchy audit tables'
deliberate FK-free linkage so audit history survives agent deletion
through the legacy CRUD DELETE path.
Result union: `enrolled { agent, correlationId }` | refusal from the
§3.3 enum (refusals also carry the correlation id, per contract 5
§4.3's end-to-end traceability). `agent` in the result is the persisted
row minus nothing sensitive (the table stores no credential material).
### 3.2 `agent.enrollment.get` (query)
By agent id; actor must be the owner (or admin). Unauthorized and
missing fold to the same `not_found` wire shape (contract 2
no-existence-oracle rule, applied family-wide for uniformity).
The query carries the same non-state envelope as the mutation
(contract 5 §4.3; contract 3's envelope reconciliation confirms closed
query responses carry it): typed request DTO with an optional
`correlationId` (generated when absent) and a typed result —
`found { agent, correlationId }` | `not_found` (the folded shape,
also carrying the correlation id). Queries take no idempotency key
(the fence binds mutations).
### 3.3 Error enum (closed, §4.2)
`validation_failed` 400 · `authentication_failed` 401 ·
`authorization_refused` 403 (owner-only paths; folded to `not_found`
where §3.2 applies) · `not_found` 404 · `conflict` 409 (the single
bounded idempotency refusal shape of §3.1 rule 5) · `precondition_failed`
422 (unresolvable credential reference; well-formed harness not in the
registry — syntactic invalidity is `validation_failed` per the §3.1
table) · `internal_fault` 500 (also the §4.4 fail-closed class when the
owning tool is unreachable; unauthorized-fallback behavior is
prohibited).
## 4. Schema delta (migration 0021, additive-only)
Extend `agents` — no new agent table, preserving custody-schema §5.2's
FK binding without amendment:
- `harness` text NULL — registered harness name; NULL for pre-existing
rows (legacy rows predate the concept).
- `enrolled_at` timestamptz NULL — set by `agent.enroll`; NULL marks a
legacy (non-enrolled) row. No backfill: enrollment is a fact this
command creates, not one to invent for existing rows.
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
separate store): `agent_audit_events` (append-only: id, event_type,
actor id, agent id — snapshot value, no FK, per §3.1 rule 6 —
correlation id, causation id, payload jsonb, created_at; per-agent
ordering index), `agent_outbox` (hierarchy-outbox shape), and
`agent_idempotency_fence` (contract 3 §4.3 shape: operation identifier,
key, acting principal, authorization scope, canonicalized-payload
digest, replay mode, committed-outcome reference (agent id), created_at;
UNIQUE (operation identifier, key)). Persona reuses the existing
`system_prompt` column; no version column (no ratified expected-version
rule names `agents` — §4.1 binds only where the owning contract defines
one).
Witnesses (real PostgreSQL, lane standard): append-only enforcement,
same-tx atomicity (agent row + credential write + fence row + audit +
outbox all-or-nothing under injected failure at multiple points,
including after the credential write), fence uniqueness on
(operation, key).
Sequencing: additive DDL via the same migration path as 00180020
(hierarchy). The docs/native-kanban-sot/SHARED-CONTRACT.md §5.3 DDL
gate binds the kanban lane's audit/proposal DDL, not this lane; if a
pending operator ruling on migration sequencing changes mechanics
lane-wide, re-check before generating 0021.
## 5. Witnesses the implementation slice must ship
1. Never-echo: enroll via `intake`, assert the value string is absent
from the HTTP result, the audit row, the outbox payload, and captured
logs.
2. Sealed-store single-copy: after intake, the credential exists only in
`provider_credentials` (sealed), and `agents` has no credential
column at all.
3. Reference-resolution refusal (`precondition_failed`, no row created).
4. Harness refusals, both codes: syntactically invalid →
`validation_failed`; well-formed registry miss →
`precondition_failed` (against the live registry).
5. Idempotency (contract 3 §4.3 set): actor-bound replay returns the
recorded outcome and executes nothing (no new agent/audit/outbox
mutation rows; a replay access event is appended); payload-digest
mismatch, replay-mode mismatch, scope mismatch, and different-actor
actor-bound replay each refuse with the single bounded `conflict`
shape; a replay is re-authorized fresh (a submitter whose
authorization was revoked since the original is refused, not
replayed); a `shared` declaration on `agent.enroll` is refused
`validation_failed` with nothing executed and no fence row
recorded (seed-only rule); two concurrent same-(operation, key)
submissions produce exactly one mutation, the loser resolving
through the replay path (no unhandled unique-violation fault).
6. Same-tx atomicity fault injection (agent / credential write / fence
/ audit / outbox), including a failure injected after the intake
credential write commits its statement — everything rolls back, no
orphan credential.
7. Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
call → zero rows in `agents`/`agent_audit_events`/`agent_outbox`/
`agent_idempotency_fence` attributable to the family.
8. `is_system` injection attempt is rejected by DTO validation.
9. Correlation-id witness (contract 5 §6.3): a correlation id submitted
on `agent.enroll` appears in its audit event(s) and in the result;
the same holds for `agent.enrollment.get`'s result; the §6.3 static
companions (no `any`-typed boundary pass-through; single audit
emitter) apply. §6.3's no-existence-oracle probe: an unauthorized
`agent.enrollment.get` of an existing agent and a get of a
nonexistent id return indistinguishable results.
10. CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
`agent.enroll` and `agent.enrollment.get` against the Gateway
succeeds with the same typed results the web client receives. The
implementation slice therefore SHIPS CLI exposure for both
operations (contract 5 §4.5 — a Gateway command without CLI
exposure is a tracked conformance gap; this design refuses to open
one).
11. Fail-closed witness (contract 5 §6.5): with the owning tool or
grant state unreachable (fault injection), the operation returns
the internal-fault or authorization-refusal class and performs no
fallback read/write.
## 6. Out of scope
Wizard orchestration (M4-6); any UI (D8/D12); un-enroll/update lifecycle
(no contract requires it in v1 — the legacy write surfaces named in §2
keep serving existing consumers); OAuth login, multi-account, comms
auto-enroll, model recommendation (PRD full flow, deferred by D11);
contract amendments (F1 recorded above for a future S2 pass). CLI
exposure is explicitly IN scope (witness 10 — contract 5 §4.5 binds it).
+4
View File
@@ -13,6 +13,10 @@ status: active
- [Documentation structure README implementation](2026-08-10-docs-structure-readme.md) — completed implementation plan for the documentation contract and atlas.
- [Documentation catalog and truth audit](2026-08-10-docs-catalog-audit.md) — audit method, evidence statuses, deliverables, and acceptance criteria.
## Feature design plans
- [Agent enrollment command design](2026-08-29-agent-enrollment-command-design.md) — v1 rank-4 enrollment command family: contract composition, command surface, schema delta, witnesses (M4-4-0).
After a plan is delivered, update the canonical guide, contract, decision, or index. Do not cite a plan as proof that intended behavior shipped.
## Related
+44
View File
@@ -514,3 +514,47 @@ itself permits, so that contract does not stretch A1 by interpretation.
default-open disclosure.
5. Every other constraint of A1 — §8.1.2's remaining bullets, §8.2 in full,
and §8.3's other acceptance criteria — is untouched.
## 10. Amendment A3 — capability-holder existence disclosure
**Status:** amendment to Amendment A2, added by reviewed PR together with
contract 2 Amendment 1 (`rbac-grant-model.md` §8, this PR), under that
amendment's ruling request (decision owner Jason). It binds if and only if
contract 2 Amendment 1 ratifies; until then §9.1.2's sole-disclosure rule
stands unmodified — which is consistent, because until ratification the
company-CRUD capability class is empty and the carve-out below has no
holders. Everything in §§19 remains binding verbatim, with exactly the one
express modification below. The detailed contract text lives in
`rbac-grant-model.md` §8.1; this amendment changes only what A2 itself
permits, so that contract does not stretch A2 by interpretation.
### 10.1 What A3 modifies in A2
1. **Capability-holder disclosure (narrows §9.1.2's sole-disclosure rule
by one carve-out).** §9.1.2 makes the directory the sole permitted
existence disclosure and keeps private companies undisclosed to
non-granted subjects everywhere. A3 admits exactly one further
disclosure channel: a subject holding the company-CRUD capability
(contract 2 §8), when exercising the hierarchy schema §5.5 visibility
command, learns the target company's existence and its old/new
visibility values through the command's redacted actor receipt —
success for an existing target (private or directory alike) versus
`not_found` for a nonexistent id — bounded exactly as contract 2 §8.1
states: no name, slug, structure, content, grant, or membership
information, and no read command of any kind. To every other
non-granted subject, private companies remain undisclosed everywhere,
including the directory; the directory remains the sole
existence-disclosure _listing_.
### 10.2 What A3 explicitly does not change
1. The directory itself is unchanged: read-only, directory-class companies
only, existence/name/slug only (§9.1.2's enumeration is narrowed for
capability holders' receipts, widened for nothing).
2. No join-request surface, no curation listing, no read command of any
family is authorized (§9.2.2 unchanged; a curation listing is a further
amendment per contract 2 §8.1).
3. The canonical audit event for visibility mutations is untouched — it
keeps hierarchy schema §5.2's full immutable target snapshot; the
capability confers no audit read (contract 2 §8.5).
4. Every other constraint of A1 and A2 is untouched.
+219
View File
@@ -24,6 +24,26 @@ fail-closed-fault, and existence-oracle observables added (§7);
role-string namespacing rule added (§4.5); ruling request now names the
interpretive resolution of PRD "admins".
Amendment 1 (company-CRUD capability): defines the capability class that
contract 1 Amendment 1 (Ruling 4b, 2026-08-28) and hierarchy schema §5.5
anticipate. §8 defines the capability as a platform-scoped, admin-assigned,
audited delegation of exactly the hierarchy schema §5.5 company visibility
command — no read command, no other company operation; the mutation's
inherent existence disclosure is ratified as a bounded carve-out to
hierarchy schema §6.7/§2.8 and to kanban SOT Amendment A2's
sole-disclosure rule — SOT Amendment A3 (native-kanban-sot.md §10, this
PR) expressly extends A2 by exactly this carve-out (§8.1). The holder
sees only a redacted actor receipt; the canonical audit event keeps
hierarchy schema §5.2's full immutable snapshot. The hierarchy role
vocabulary (§2), every evaluation rule (§3), and grant management (§4) are
untouched: the capability is not a `hierarchy_grants.role` value and
evaluates outside the chain; capability-row deletion joins §6.1's
revocation enumeration (§8.4). Until this amendment ratifies, the capability
class is empty and
the visibility command remains admin-only (hierarchy schema §5.5 states
this fallback; the shipped gate at
`apps/gateway/src/hierarchy/hierarchy.repository.ts` implements it).
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
each hierarchy level confers, how grants evaluate down the chain, how
revocation propagates, and who may manage grants. Out of scope: the hierarchy
@@ -242,6 +262,191 @@ contract 1 §6):
8. Transfer: both-sides `owner` accepted, each single-side case refused
(completing contract 1 §6.5).
## 8. Company-CRUD capability (Amendment 1)
Hierarchy schema §5.5 authorizes the company visibility mutation for
exactly two actor classes: platform admins and "subjects holding the
company-CRUD capability that a follow-up amendment to contract 2 will
define". This section is that definition. The name is historical — coined
in contract 1 Amendment 1 before the capability's content was fixed — and
confers nothing by connotation: the ratified content is exactly §8.1.
Company _creation_ is already ruled open to active users and always
private (contract 3 §5.2, Ruling 4); rename, delete, and transfer of
companies remain hierarchy `owner` operations (§2.3, §5); none of those is
part of this capability, and widening it to any other operation is a
further amendment, not an implementation decision.
1. **Content: exactly one command, no read command, disclosure stated.**
Holding the capability authorizes executing the hierarchy schema §5.5
visibility command (`companies.visibility`, both directions:
`private → directory` and `directory → private`) on any company in the
deployment, and no other command of any family. It confers **no read
command**: no company enumeration, no curation listing, no structure
read. The practical flow this implies is deliberate: to publish a
private company, the holder is given the target identifier by the
requesting company `owner` out of band; to unpublish, the target is
already directory-listed. A curation listing for capability holders,
if ever wanted, is a further amendment with its own disclosure
analysis under hierarchy schema §6.7.
**Existence disclosure carve-out, stated rather than pretended away:**
exercising a mutation inherently discloses its target's existence.
The command's result distinguishes an existing company (success, for
private and directory targets alike) from a nonexistent id
(`not_found`), so a holder presenting candidate ids learns existence —
exactly as a platform admin already does through the same command.
This amendment ratifies that disclosure as part of the §5.5 curation
authority, bounded as follows. The holder-visible surface is the
command's **actor receipt** — the mutation result payload, carrying
exactly the target id, old visibility, and new visibility, and
**nothing else**: no name, slug, structure, content, grant, or
membership information. The actor receipt is a redacted projection
distinct from the **canonical audit event**, which is unchanged by
this amendment: it keeps hierarchy schema §5.2's deletion-safe
immutable target snapshot (id, slug, and parent chain at event time)
in full. The two never converge on the holder: the capability confers
no audit read (§8.5), so the canonical event — and with it the slug
and parent chain — is reachable only by subjects independently
authorized to read audit data, never through this capability. A
successful publish additionally makes the target directory-listed to
every authenticated user; that is the command's ratified purpose
(hierarchy schema §5.5), not a leak. Hierarchy schema §6.7's
existence-oracle rule and §2.8's directory-only disclosure are amended
by exactly this carve-out for capability holders, kanban SOT Amendment
A3 (native-kanban-sot.md §10, this PR) expressly extends A2's
sole-disclosure enumeration by the same carve-out, and all three are
otherwise untouched. Witnessed in §8.6.3.
2. **Holding: platform-scoped assignment, user subjects only.** The
capability is not a hierarchy grant: it attaches to no node, has no
role, and never enters §3 chain evaluation. It is held via a
`platform_capabilities` table whose column set is exactly (nothing
else, per the contract 1 §2.7 exhaustiveness discipline):
- `id` — uuid, primary key;
- `user_id` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
- `capability` — text, NOT NULL, constraint-checked against exactly
`company_crud`;
- `granted_by` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
- `created_at` — timestamptz, NOT NULL;
- UNIQUE (`user_id`, `capability`).
The user FKs are **text**, not uuid, because `users.id` is a BetterAuth
text key (`packages/db/src/schema.ts`; custody schema records the same)
— PostgreSQL cannot reference a text primary key with a uuid column.
This matches the shipped `hierarchy_grants` shape exactly: uuid
surrogate `id`, text FKs to `users`.
Both user FKs are RESTRICT for the same reason contract 1 §3.3 pins
RESTRICT on principal FKs: the identity contract (§7.3) gates user
deletion, and a cascade here could silently destroy a capability
without its §8.3 revocation audit event. Revocation is row deletion
through the §8.3 command — there is no other removal path, no expiry
column, and no tombstone. A deactivated holder confers nothing while
deactivated: identity contract §7.1 denies all authorization to
deactivated accounts, and the §8.4 predicate evaluates on the
authenticated live user. No team subjects (§1.4's suspension reasoning
applies with more force here — a workspace-bound team holding
deployment-wide curation authority has no ratified meaning).
3. **Assignment is instance administration on the normal admin surface.**
Only platform admins (`users.role = 'admin'`) may assign or revoke the
capability, through an ordinary admin command (the same command class
`AdminGuard` governs, §1.1) — not through direct table writes.
Assignment delegates a slice of instance administration and is itself
an instance-administration act under §1.1. A capability holder as such
may NOT assign or revoke it (no self-propagation). Every assignment
and revocation is a semantic audit event carrying actor, verb, subject
user, and capability; serialized capability strings are namespaced per
§4.5 (`platform-capability:company-crud` — a bare `company_crud` in
any serialized artifact is non-conformant).
4. **Evaluation and revocation follow this contract's existing rules.**
The hierarchy schema §5.5 command's authorization predicate is:
`users.role = 'admin'` OR a live `platform_capabilities` row
(`user_id`, `company_crud`). Both disjuncts are evaluated live and
fail closed per §3.5 — **independently**: with capability state
unreadable (fault), the capability disjunct denies, but a platform
admin whose `users.role` is readable remains authorized through the
admin disjunct; with role state unreadable, the admin disjunct denies
likewise. A decision that can read neither denies. Capability-row
deletion is hereby added to §6.1's enumerated revocation paths:
it propagates identically, under §6.2's bound, on every transport —
no new HTTP/MCP command authorized by the deleted row after the
revoking transaction commits, and any cached authorization is
invalidated in the revoking transaction (§3.5).
5. **What it does not confer**, stated so implementing PRs cannot drift:
no hierarchy grant or effective role at any node; no workspace
authorization or membership; no content, structure, or roll-up read;
no grant management (§4.1 unchanged); no MCP scope; no other instance
administration (user management, system settings, provider
configuration remain platform-admin-only); no company create, rename,
delete, or transfer. Hierarchy schema §5.5's rule that a company
`owner` as such may NOT change visibility is unchanged — `owner` and
this capability are disjoint authorities that combine only by a
subject holding both.
6. **Verification requirements** (extends §7, binding on implementing
PRs):
1. Schema witnesses (real PostgreSQL, `ci-postgres` service in the
`test` CI step): the `capability` CHECK constraint rejects any
value outside `company_crud`; NOT NULL enforced on every declared
NOT NULL column; UNIQUE (`user_id`, `capability`) rejects a
duplicate; both user FKs reject a dangling reference AND deleting a
referenced user is refused (RESTRICT witnessed in both directions);
the table's column set is exactly the §8.2 declared set (contract 1
§6.2 discipline).
2. Capability-only command matrix — the witness that proves "exactly
one command", not merely "at least one": a non-admin holder with no
other grants succeeds on the visibility command in **both**
directions with contract 1 §5.2's audit event (old and new values
as semantic content), and the **same** actor is refused, case by
enumerated case: every hierarchy mutation family (company/child
create under another's node, rename, delete, transfer); grant
create/change/revoke; the workspace read and write command
families; roll-up reads; structure reads — including the
not-found-indistinguishable refusal on a structure read of the very
company they just mutated (hierarchy schema §6.7); every
instance-administration surface other than the visibility command
(user management, system settings, provider configuration, and
capability assign/revoke itself); and MCP scope derivation yields
nothing — the §7.4 deny-by-default matrix gains this row. Company
creation compares against an eligible-user baseline: the holder's
create behaves exactly as any active user's — always `private`,
and a creation request carrying a visibility argument is refused
for holder and baseline alike (contract 3 §5.2).
3. Disclosure bound (§8.1 carve-out witnessed, receipt and canonical
event separately): the mutation result for a private-valid target,
a directory-valid target, and a nonexistent id is exactly {success,
success, `not_found`}; the actor receipt for a success carries
exactly {target id, old visibility, new visibility} and no result
or error payload carries name, slug, structure, content, grant, or
membership data; the canonical audit event for the same mutation —
asserted directly against the hierarchy outbox, not through any
holder-facing surface — carries hierarchy schema §5.2's full
immutable snapshot (id, slug, parent chain); and the holder's
attempt to read audit data is refused (no audit read conferred,
§8.5), proving the receipt/event separation reaches the holder as
a redaction, not a weakened event.
4. Assignment path, both polarities: a platform admin assigns and
revokes through the normal admin command (positive witnesses —
assign then observe the §8.6.2 allow, revoke then observe deny); a
non-admin — including a current capability holder — is refused
assign and revoke; every assign/revoke produces its audit event
with the namespaced string (§8.3); a direct-write path that skips
the command surface is non-conformant (the §8.3 command is the only
writer of `platform_capabilities`).
5. Revocation joins the §7.6 matrix: assignment is decision-time-live
(capability assigned → the holder's next visibility command allows,
no re-login); after row deletion, the ex-holder's next visibility
command is refused **on every exposed transport**, measured with
the revocation and the decision on distinct physical connections; a
cached-authorization implementation proves transactional
invalidation (§3.5). Fail-closed fault witnesses, both disjuncts
(§8.4): with `platform_capabilities` unreadable, a non-admin holder
is denied while a platform admin remains authorized; with role
state unreadable, the admin disjunct denies.
6. Owner-as-such refusal re-witnessed: hierarchy schema §6.9's
owner-cannot-publish witness re-asserted with the
`platform_capabilities` table present and empty for that owner.
## Ruling request
Ratify sections 17 as written, with one decision embedded and one
@@ -257,3 +462,17 @@ interpretive resolution named:
platform admins. A1 §8.1.3 does not attribute grant declaration to
platform admins, and the §1.1 decision above is what makes this reading
binding.
## Ruling request (Amendment 1)
Ratify §8, the Amendment 1 header note, and kanban SOT Amendment A3
(native-kanban-sot.md §10 — the express A2 carve-out extension, which
binds only with this ratification) as written, with one decision
embedded:
- Decision: the company-CRUD capability is a platform-scoped,
admin-assigned, audited delegation of exactly the hierarchy schema §5.5
visibility command — no read command, no other company operation, with
the mutation's inherent existence disclosure ratified as a bounded
carve-out (§8.1). Say "agreed" or name the additional operations (or
the curation listing) you want it to carry.
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
import { createMissionTasksRepo } from './mission-tasks.js';
/**
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
* the sole path that authors mission_tasks.status from caller input (storage
* tier migration is row transport and preserves stored values; the generic
* storage adapters have no mission_tasks caller), and it must never forward a
* caller-supplied status to the database on create or update. Callers keep
* working (the field is accepted and ignored), so these tests assert on what
* reaches the Drizzle chain, not on rejection.
*/
function makeInsertDb(returned: unknown[]) {
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
return { db: { insert: vi.fn(() => ({ values })) }, values };
}
function makeUpdateDb(returned: unknown[]) {
const set = vi.fn((_v: unknown) => ({
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
}));
return { db: { update: vi.fn(() => ({ set })) }, set };
}
describe('createMissionTasksRepo — status write prohibition', () => {
it('create strips a caller-supplied status before insert', async () => {
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
const repo = createMissionTasksRepo(db as never);
const result = await repo.create({
missionId: 'm1',
userId: 'u1',
status: 'done',
description: 'd',
} as never);
expect(values).toHaveBeenCalledTimes(1);
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in inserted).toBe(false);
expect(inserted.missionId).toBe('m1');
expect(inserted.description).toBe('d');
expect(result.id).toBe('mt1');
});
it('create without status still inserts (DB default applies)', async () => {
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
const repo = createMissionTasksRepo(db as never);
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in inserted).toBe(false);
});
it('update strips a caller-supplied status but keeps the other fields', async () => {
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
const repo = createMissionTasksRepo(db as never);
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
expect(set).toHaveBeenCalledTimes(1);
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in updated).toBe(false);
expect(updated.notes).toBe('n');
expect(updated.updatedAt).toBeInstanceOf(Date);
expect(result?.id).toBe('mt1');
});
it('update with only status degenerates to a timestamp-only update', async () => {
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
const repo = createMissionTasksRepo(db as never);
await repo.update('mt1', { status: 'blocked' } as never);
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
expect(Object.keys(updated)).toEqual(['updatedAt']);
});
});
+20 -2
View File
@@ -3,6 +3,24 @@ import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
export type MissionTask = typeof missionTasks.$inferSelect;
export type NewMissionTask = typeof missionTasks.$inferInsert;
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
// write source through the N-1 window. This repo is the sole path that authors
// status from caller input, so the field is stripped here — accepted and
// ignored rather than rejected, because the legacy surface is frozen with
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
// surfaces touch the column and are deliberately NOT stripped:
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
// must preserve the stored value verbatim, and the generic table-keyed storage
// adapters register mission_tasks but have no caller that targets it (runtime
// callers use fixed collection constants). Neither authors a new status. The
// column keeps its DB default, stays declared and readable, and is retired
// only after no readers remain.
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
const rest = { ...data };
delete rest.status;
return rest;
}
export function createMissionTasksRepo(db: Db) {
return {
async findByMission(missionId: string): Promise<MissionTask[]> {
@@ -30,14 +48,14 @@ export function createMissionTasksRepo(db: Db) {
},
async create(data: NewMissionTask): Promise<MissionTask> {
const rows = await db.insert(missionTasks).values(data).returning();
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
const rows = await db
.update(missionTasks)
.set({ ...data, updatedAt: new Date() })
.set({ ...stripStatus(data), updatedAt: new Date() })
.where(eq(missionTasks.id, id))
.returning();
return rows[0];
@@ -0,0 +1,47 @@
CREATE TYPE "public"."agent_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
CREATE TABLE "agent_audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"seq" bigint GENERATED ALWAYS AS IDENTITY (sequence name "agent_audit_events_seq_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"event_type" text NOT NULL,
"actor_id" text NOT NULL,
"agent_id" uuid NOT NULL,
"correlation_id" text NOT NULL,
"causation_id" uuid,
"payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_audit_events_type_check" CHECK (event_type IN ('agent.enrolled', 'agent.enrollment.replayed'))
);
--> statement-breakpoint
CREATE TABLE "agent_idempotency_fence" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"operation" text NOT NULL,
"idempotency_key" text NOT NULL,
"actor_id" text NOT NULL,
"authorization_scope" text NOT NULL,
"payload_digest" text NOT NULL,
"replay_mode" text DEFAULT 'actor-bound' NOT NULL,
"outcome_agent_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "agent_idempotency_fence_replay_mode_check" CHECK (replay_mode IN ('actor-bound', 'shared'))
);
--> statement-breakpoint
CREATE TABLE "agent_outbox" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"correlation_id" text NOT NULL,
"status" "agent_outbox_status" DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"delivered_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "harness" text;--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "enrolled_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "agent_audit_events" ADD CONSTRAINT "agent_audit_events_causation_id_agent_audit_events_id_fk" FOREIGN KEY ("causation_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agent_outbox" ADD CONSTRAINT "agent_outbox_event_id_agent_audit_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."agent_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "agent_audit_events_seq_idx" ON "agent_audit_events" USING btree ("seq");--> statement-breakpoint
CREATE INDEX "agent_audit_events_agent_seq_idx" ON "agent_audit_events" USING btree ("agent_id","seq");--> statement-breakpoint
CREATE INDEX "agent_audit_events_correlation_idx" ON "agent_audit_events" USING btree ("correlation_id");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_idempotency_fence_operation_key_idx" ON "agent_idempotency_fence" USING btree ("operation","idempotency_key");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_outbox_event_idx" ON "agent_outbox" USING btree ("event_id");--> statement-breakpoint
CREATE INDEX "agent_outbox_status_created_idx" ON "agent_outbox" USING btree ("status","created_at");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -148,6 +148,13 @@
"when": 1787963521142,
"tag": "0020_special_betty_brant",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1788053011351,
"tag": "0021_agent_enrollment",
"breakpoints": true
}
]
}
@@ -0,0 +1,412 @@
/**
* Agent enrollment schema witnesses — M4-4a, the schema-level half of the
* witness list in docs/plans/2026-08-29-agent-enrollment-command-design.md §5.
*
* Witnesses the guarantees migration 0021's tables themselves carry: the
* event-type CHECK, monotonic per-agent append order (`seq`), deletion-safe
* linkage (no foreign key from the events or fence tables into `agents` —
* rows survive a legacy CRUD DELETE of the agent), the causation self-FK,
* the outbox's FK/uniqueness/status shape, the fence's UNIQUE
* (operation, key) and replay-mode CHECK, and the nullable enrollment
* columns on `agents` (legacy rows insert without them). The command-level
* witnesses (never-echo, same-tx atomicity, replay semantics, correlation,
* CLI parity, fail-closed) belong to the M4-4b implementation slice.
*
* Two legs run the same witness body:
* - PGlite (WASM Postgres): always runs.
* - Real PostgreSQL: runs when DATABASE_URL is set — the binding witness;
* CI migrates ci-postgres before `pnpm test`.
*/
import { randomUUID } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createDb } from './client.js';
import { createPgliteDb } from './client-pglite.js';
import { runPgliteMigrations } from './migrate.js';
import { agentAuditEvents, agentIdempotencyFence, agentOutbox, agents } from './schema.js';
type AnyDb = {
db: {
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
};
close: () => Promise<void>;
};
/** Match a constraint failure anywhere along drizzle's cause chain. */
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
let err: unknown;
try {
await p;
} catch (e) {
err = e;
}
expect(err, label || 'expected the statement to be refused').toBeDefined();
const messages: string[] = [];
let cur: unknown = err;
while (cur instanceof Error) {
messages.push(cur.message);
cur = (cur as { cause?: unknown }).cause;
}
expect(messages.join(' | '), label).toMatch(re);
}
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
}
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
const T = `agent-e-${randomUUID().slice(0, 8)}`;
type EventInsert = typeof agentAuditEvents.$inferInsert;
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
return {
eventType: 'agent.enrolled',
actorId: `${T}-actor`,
agentId: randomUUID(),
correlationId: `${T}-corr-${randomUUID()}`,
payload: {
harness: 'claude-code',
provider: 'anthropic',
name: 'x',
credentialMode: 'reference',
},
...overrides,
};
}
type FenceInsert = typeof agentIdempotencyFence.$inferInsert;
function fenceRow(overrides: Partial<FenceInsert> = {}): FenceInsert {
return {
operation: 'agent.enroll',
idempotencyKey: `${T}-${randomUUID()}`,
actorId: `${T}-actor`,
authorizationScope: 'platform-user',
payloadDigest: `${T}-digest`,
outcomeAgentId: randomUUID(),
...overrides,
};
}
function witnessSuite(getHandle: () => AnyDb): void {
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
afterAll(async () => {
const d = db();
await d.execute(sql`DELETE FROM agent_outbox WHERE correlation_id LIKE ${T + '%'}`);
// Caused events first: the causation self-FK is RESTRICT.
await d.execute(
sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'} AND causation_id IS NOT NULL`,
);
await d.execute(sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM agent_idempotency_fence WHERE actor_id LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM agents WHERE name LIKE ${T + '%'}`);
});
// ── agents: nullable enrollment columns (no backfill semantics) ────────────
it('legacy agent rows insert without enrollment columns; enrolled rows carry both', async () => {
const legacyId = randomUUID();
await db()
.insert(agents)
.values({
id: legacyId,
name: `${T}-legacy`,
provider: 'anthropic',
model: 'claude-fable-5',
});
const legacy = rows(
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${legacyId}`),
)[0]!;
expect(legacy['harness']).toBeNull();
expect(legacy['enrolled_at']).toBeNull();
const enrolledId = randomUUID();
await db()
.insert(agents)
.values({
id: enrolledId,
name: `${T}-enrolled`,
provider: 'anthropic',
model: 'claude-fable-5',
harness: 'claude-code',
enrolledAt: new Date(),
});
const enrolled = rows(
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${enrolledId}`),
)[0]!;
expect(enrolled['harness']).toBe('claude-code');
expect(enrolled['enrolled_at']).not.toBeNull();
});
// ── agent_audit_events: CHECK, ordering, deletion-safe linkage ─────────────
it('accepts both declared event types and refuses an undeclared one', async () => {
await db()
.insert(agentAuditEvents)
.values(eventRow({ eventType: 'agent.enrolled' }));
await db()
.insert(agentAuditEvents)
.values(eventRow({ eventType: 'agent.enrollment.replayed' }));
await expectViolation(
db()
.insert(agentAuditEvents)
.values(eventRow({ eventType: 'agent.deleted' })),
/type_check|violates check/i,
'undeclared event type must be refused',
);
});
it('assigns strictly increasing seq in insert order for one agent', async () => {
const agentId = randomUUID();
const c1 = `${T}-seq-1-${randomUUID()}`;
const c2 = `${T}-seq-2-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ agentId, correlationId: c1 }));
await db()
.insert(agentAuditEvents)
.values(eventRow({ agentId, eventType: 'agent.enrollment.replayed', correlationId: c2 }));
const res = rows(
await db().execute(
sql`SELECT correlation_id, seq FROM agent_audit_events WHERE agent_id = ${agentId} ORDER BY seq ASC`,
),
);
expect(res.map((r) => r['correlation_id'])).toEqual([c1, c2]);
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
});
it('has no foreign key into agents, and events survive agent deletion', async () => {
const fks = rows(
await db().execute(sql`
SELECT ccu.table_name AS referenced_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_audit_events'
`),
);
// The causation self-FK is the ONLY foreign key on the events table.
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['agent_audit_events']);
const agentId = randomUUID();
await db()
.insert(agents)
.values({ id: agentId, name: `${T}-doomed`, provider: 'anthropic', model: 'claude-fable-5' });
const corr = `${T}-survive-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ agentId, correlationId: corr }));
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
const after = rows(
await db().execute(
sql`SELECT agent_id FROM agent_audit_events WHERE correlation_id = ${corr}`,
),
);
expect(after).toHaveLength(1);
expect(after[0]!['agent_id']).toBe(agentId);
});
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
await expectViolation(
db()
.insert(agentAuditEvents)
.values(eventRow({ causationId: randomUUID() })),
/foreign key/i,
'causation must reference an existing event',
);
const causeCorr = `${T}-cause-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ correlationId: causeCorr }));
const cause = rows(
await db().execute(
sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${causeCorr}`,
),
)[0]!;
await db()
.insert(agentAuditEvents)
.values(
eventRow({
eventType: 'agent.enrollment.replayed',
causationId: cause['id'] as string,
}),
);
await expectViolation(
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${cause['id'] as string}`),
/foreign key/i,
'a cause with dependent events must not be deletable',
);
});
// ── agent_outbox shape ─────────────────────────────────────────────────────
it('outbox rows require an existing event, one outbox row per event, closed status enum', async () => {
await expectViolation(
db()
.insert(agentOutbox)
.values({ eventId: randomUUID(), correlationId: `${T}-corr` }),
/foreign key/i,
'outbox must reference an existing event',
);
const corr = `${T}-ob-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ correlationId: corr }));
const event = rows(
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
)[0]!;
const eventId = event['id'] as string;
await db().insert(agentOutbox).values({ eventId, correlationId: corr });
await expectViolation(
db()
.insert(agentOutbox)
.values({ eventId, correlationId: `${T}-ob2` }),
/duplicate key|unique/i,
'one outbox record per event',
);
await expectViolation(
db().execute(
sql`INSERT INTO agent_outbox (event_id, correlation_id, status)
VALUES (${eventId}, ${`${T}-ob3`}, 'failed')`,
),
/invalid input value for enum|22P02/i,
'status outside pending/processing/delivered must be refused',
);
});
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
const corr = `${T}-obr-${randomUUID()}`;
await db()
.insert(agentAuditEvents)
.values(eventRow({ correlationId: corr }));
const event = rows(
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
)[0]!;
await db()
.insert(agentOutbox)
.values({ eventId: event['id'] as string, correlationId: corr });
await expectViolation(
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${event['id'] as string}`),
/foreign key/i,
);
});
// ── agent_idempotency_fence: (operation, key) uniqueness, mode CHECK ───────
it('refuses a duplicate (operation, key) pair but allows the same key under another operation', async () => {
const key = `${T}-fence-${randomUUID()}`;
await db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key }));
await expectViolation(
db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key })),
/duplicate key|unique/i,
'fence uniqueness is (operation, key)',
);
// Same key, different operation identifier: a distinct fence.
await db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key, operation: 'agent.other' }));
});
it('defaults replay mode to actor-bound and refuses an undeclared mode', async () => {
const key = `${T}-mode-${randomUUID()}`;
await db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key }));
const row = rows(
await db().execute(
sql`SELECT replay_mode FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
),
)[0]!;
expect(row['replay_mode']).toBe('actor-bound');
await expectViolation(
db()
.insert(agentIdempotencyFence)
.values(fenceRow({ replayMode: 'unbound' as 'actor-bound' })),
/replay_mode_check|violates check/i,
'a mode outside actor-bound/shared must be refused',
);
});
it('fence has no foreign key at all, and rows survive agent deletion', async () => {
const fks = rows(
await db().execute(sql`
SELECT ccu.table_name AS referenced_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_idempotency_fence'
`),
);
expect(fks).toHaveLength(0);
const agentId = randomUUID();
await db()
.insert(agents)
.values({
id: agentId,
name: `${T}-fdoomed`,
provider: 'anthropic',
model: 'claude-fable-5',
});
const key = `${T}-fsurvive-${randomUUID()}`;
await db()
.insert(agentIdempotencyFence)
.values(fenceRow({ idempotencyKey: key, outcomeAgentId: agentId }));
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
const after = rows(
await db().execute(
sql`SELECT outcome_agent_id FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
),
);
expect(after).toHaveLength(1);
expect(after[0]!['outcome_agent_id']).toBe(agentId);
});
}
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
describe('agent enrollment schema witnesses — PGlite', () => {
let dir: string;
let handle: ReturnType<typeof createPgliteDb>;
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'agent-enroll-witness-'));
handle = createPgliteDb(dir);
await runPgliteMigrations(handle);
});
afterAll(async () => {
await handle.close();
rmSync(dir, { recursive: true, force: true });
});
witnessSuite(() => handle as unknown as AnyDb);
});
// ── Leg 2: real PostgreSQL (binding witness, ci-postgres in CI) ──────────────
const hasPostgres = Boolean(process.env['DATABASE_URL']);
describe.skipIf(!hasPostgres)('agent enrollment schema witnesses — real PostgreSQL', () => {
let handle: ReturnType<typeof createDb>;
beforeAll(() => {
handle = createDb(process.env['DATABASE_URL']!);
});
afterAll(async () => {
await handle.close();
});
witnessSuite(() => handle as unknown as AnyDb);
});
+111
View File
@@ -302,6 +302,11 @@ export const agents = pgTable(
skills: jsonb('skills').$type<string[]>(),
isSystem: boolean('is_system').notNull().default(false),
config: jsonb('config'),
// Enrollment (M4-4, docs/plans/2026-08-29-agent-enrollment-command-design.md §4).
// NULL on both marks a legacy (non-enrolled) row; no backfill — enrollment
// is a fact the rank-4 command creates, not one to invent for existing rows.
harness: text('harness'),
enrolledAt: timestamp('enrolled_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
@@ -1279,3 +1284,109 @@ export const hierarchyOutbox = pgTable(
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
],
);
// ---------------------------------------------------------------------------
// Agent enrollment (M4-4) — rank-4 command family audit/outbox/fence stores.
// Design: docs/plans/2026-08-29-agent-enrollment-command-design.md §4.
// Pattern reuse from the hierarchy audit/outbox pair, separate store. Audit
// rows reference the agent by snapshot id, deliberately with NO FK, so audit
// history survives agent deletion through the legacy CRUD DELETE path.
// Idempotency for this family lives in agent_idempotency_fence (contract 3
// §4.3 envelope, ratified into contract 5 §4 via contract 3 §7 item 4) — the
// audit and outbox tables carry no idempotency key of their own.
export const AGENT_AUDIT_EVENT_TYPES = [
// Semantic mutation event of agent.enroll.
'agent.enrolled',
// Non-mutation access class: a passing idempotent replay appends this and
// nothing else (accessing principal, current correlation id, fence-row
// reference in the payload).
'agent.enrollment.replayed',
] as const;
export const agentAuditEvents = pgTable(
'agent_audit_events',
{
id: uuid('id').primaryKey().defaultRandom(),
// Global append order; per-agent ordering is a filter on agent_id ordered
// by seq.
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
eventType: text('event_type').notNull(),
// No FK: audit events outlive every principal and every target.
actorId: text('actor_id').notNull(),
agentId: uuid('agent_id').notNull(),
correlationId: text('correlation_id').notNull(),
causationId: uuid('causation_id').references((): AnyPgColumn => agentAuditEvents.id, {
onDelete: 'restrict',
}),
// Immutable snapshot at event time; never carries credential material
// (§3.1 rule 1: actor, agent id, harness, provider, name, credentialMode).
payload: jsonb('payload').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('agent_audit_events_seq_idx').on(t.seq),
index('agent_audit_events_agent_seq_idx').on(t.agentId, t.seq),
index('agent_audit_events_correlation_idx').on(t.correlationId),
check(
'agent_audit_events_type_check',
sql`event_type IN ('agent.enrolled', 'agent.enrollment.replayed')`,
),
],
);
export const agentOutboxStatusEnum = pgEnum('agent_outbox_status', [
'pending',
'processing',
'delivered',
]);
export const agentOutbox = pgTable(
'agent_outbox',
{
id: uuid('id').primaryKey().defaultRandom(),
// FK into the append-only events table: never dangles, RESTRICT is safe.
eventId: uuid('event_id')
.notNull()
.references(() => agentAuditEvents.id, { onDelete: 'restrict' }),
correlationId: text('correlation_id').notNull(),
status: agentOutboxStatusEnum('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
},
(t) => [
uniqueIndex('agent_outbox_event_idx').on(t.eventId),
index('agent_outbox_status_created_idx').on(t.status, t.createdAt),
],
);
// Contract 3 §4.3 fence shape. Uniqueness is (operation, key); the recorded
// replay mode is always 'actor-bound' for this family (`shared` is seed-only
// and refused at validation — design §3.1), but the column keeps the ratified
// envelope shape and serves the mode-mismatch collision check. The payload
// digest input EXCLUDES the credential value (design §3.1 rule 5).
export const agentIdempotencyFence = pgTable(
'agent_idempotency_fence',
{
id: uuid('id').primaryKey().defaultRandom(),
operation: text('operation').notNull(),
idempotencyKey: text('idempotency_key').notNull(),
// No FK: fence rows outlive principals, mirroring the audit tables.
actorId: text('actor_id').notNull(),
authorizationScope: text('authorization_scope').notNull(),
payloadDigest: text('payload_digest').notNull(),
replayMode: text('replay_mode').notNull().default('actor-bound'),
// Committed-outcome reference (the enrolled agent's id). Snapshot value,
// no FK: the fence must keep answering replays after a legacy DELETE.
outcomeAgentId: uuid('outcome_agent_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('agent_idempotency_fence_operation_key_idx').on(t.operation, t.idempotencyKey),
check(
'agent_idempotency_fence_replay_mode_check',
sql`replay_mode IN ('actor-bound', 'shared')`,
),
],
);
@@ -47,7 +47,7 @@ usage_error() {
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
-i|--issue|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE="$2"
shift 2
@@ -12,6 +12,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
BODY_FILE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
@@ -24,7 +25,7 @@ usage_error() {
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
-i|--issue|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
@@ -36,11 +37,18 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
--body-file)
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
BODY_FILE="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -n, --number Issue number (required; canonical)"
echo " -i, --issue Alias for --number"
echo " -b, --body Comment to add before closing (optional; canonical)"
echo " -c, --comment Alias for --body"
echo " -h, --help Show this help"
@@ -54,6 +62,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
# exclusive with an explicit --body/--comment value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
COMMENT=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "issue number is required (-i/--issue)"
fi
@@ -31,6 +31,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
BODY_FILE=""
LOGIN_OVERRIDE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
@@ -45,7 +46,7 @@ usage_error() {
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
-i|--issue|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
@@ -58,6 +59,12 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
--body-file)
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
BODY_FILE="$2"
shift 2
;;
-l|--login)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LOGIN_OVERRIDE="$2"
@@ -67,7 +74,8 @@ while [[ $# -gt 0 ]]; do
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -n, --number Issue number (required; canonical)"
echo " -i, --issue Alias for --number"
echo " -b, --body Comment text (required; canonical)"
echo " -c, --comment Alias for --body"
echo " -l, --login Override the detected Gitea tea login for this call"
@@ -82,6 +90,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
# exclusive with an explicit --body/--comment value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
COMMENT=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "issue number is required (-i/--issue)"
fi
@@ -10,6 +10,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Default values
TITLE=""
BODY=""
BODY_FILE=""
LABELS=""
MILESTONE=""
INTERACTIVE=false
@@ -100,6 +101,12 @@ while [[ $# -gt 0 ]]; do
BODY="$2"
shift 2
;;
--body-file)
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
BODY_FILE="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
@@ -124,6 +131,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
# exclusive with an explicit --body/--comment value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
BODY=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
if [[ "$INTERACTIVE" == true ]]; then
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
ISSUE_NUMBER=""
TITLE=""
BODY=""
BODY_FILE=""
LABELS=""
MILESTONE=""
@@ -25,7 +26,7 @@ usage_error() {
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
-i|--issue|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
@@ -40,6 +41,12 @@ while [[ $# -gt 0 ]]; do
BODY="$2"
shift 2
;;
--body-file)
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
BODY_FILE="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
@@ -54,7 +61,8 @@ while [[ $# -gt 0 ]]; do
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -n, --number Issue number (required; canonical)"
echo " -i, --issue Alias for --number"
echo " -t, --title New title"
echo " -b, --body New body/description"
echo " -l, --labels Labels (comma-separated, replaces existing)"
@@ -70,6 +78,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
# exclusive with an explicit --body/--comment value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
BODY=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "issue number is required (-i/--issue)"
fi
@@ -54,7 +54,7 @@ while [[ $# -gt 0 ]]; do
STATE="$2"
shift 2
;;
-l|--label)
-l|--label|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABEL="$2"
shift 2
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
BODY_FILE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
@@ -23,7 +24,7 @@ usage_error() {
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
-i|--issue|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
@@ -35,11 +36,18 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
--body-file)
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
BODY_FILE="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -n, --number Issue number (required; canonical)"
echo " -i, --issue Alias for --number"
echo " -b, --body Comment to add when reopening (optional; canonical)"
echo " -c, --comment Alias for --body"
echo " -h, --help Show this help"
@@ -53,6 +61,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
# exclusive with an explicit --body/--comment value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
COMMENT=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "issue number is required (-i/--issue)"
fi
@@ -81,7 +81,7 @@ if comments:
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
-i|--issue|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
@@ -90,7 +90,8 @@ while [[ $# -gt 0 ]]; do
echo "Usage: issue-view.sh -i <issue_number>"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -n, --number Issue number (required; canonical)"
echo " -i, --issue Alias for --number"
echo ""
echo "Comments are always included (tea --comments / Gitea API /comments)."
echo " -h, --help Show this help"
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
PR_NUMBER=""
COMMENT=""
BODY_FILE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
@@ -35,6 +36,12 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
--body-file)
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
BODY_FILE="$2"
shift 2
;;
-h|--help)
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
echo ""
@@ -53,6 +60,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
# exclusive with an explicit --body/--comment value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
COMMENT=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
if [[ -z "$PR_NUMBER" ]]; then
usage_error "PR number is required (-n/--number)"
fi
@@ -10,6 +10,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Default values
TITLE=""
BODY=""
BODY_FILE=""
BASE_BRANCH=""
HEAD_BRANCH=""
LABELS=""
@@ -158,6 +159,12 @@ while [[ $# -gt 0 ]]; do
BODY="$2"
shift 2
;;
--body-file)
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
BODY_FILE="$2"
shift 2
;;
-B|--base)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BASE_BRANCH="$2"
@@ -197,6 +204,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
# exclusive with an explicit --body/--comment value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
BODY=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
# If no title but issue provided, generate title
if [[ -z "$TITLE" ]] && [[ -n "$ISSUE" ]]; then
TITLE="Fixes #$ISSUE"
+16 -4
View File
@@ -13,21 +13,33 @@ OUTPUT_FILE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [--host host] [-o <output_file>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-o|--output)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
OUTPUT_FILE="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
--host)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
HOST_OVERRIDE="$2"
shift 2
;;
@@ -40,18 +52,18 @@ while [[ $# -gt 0 ]]; do
echo " --host Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)"
echo " -o, --output Output file (optional, prints to stdout if omitted)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)" >&2
exit 1
usage_error "PR number is required (-n/--number)"
fi
if [[ -n "$REPO_OVERRIDE" ]]; then
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
TITLE=""
BODY=""
BODY_FILE=""
BASE_BRANCH=""
DRAFT_MODE=""
LOGIN_OVERRIDE=""
@@ -65,6 +66,7 @@ while [[ $# -gt 0 ]]; do
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
--body-file) [[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"; BODY_FILE="$2"; shift 2 ;;
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
--draft)
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
@@ -80,6 +82,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into BODY (file or stdin '-');
# exclusive with an explicit --body value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$BODY" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
BODY=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
BODY=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
+15 -4
View File
@@ -34,29 +34,41 @@ Examples:
$(basename "$0") -s merged -a username # Merged PRs by user
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
EOF
exit "${1:-1}"
exit "${1:-2}"
}
# Parse arguments
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case $1 in
-s|--state)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
STATE="$2"
shift 2
;;
-l|--label)
-l|--label|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABEL="$2"
shift 2
;;
-a|--author)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
AUTHOR="$2"
shift 2
;;
-n|--limit)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LIMIT="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
@@ -64,8 +76,7 @@ while [[ $# -gt 0 ]]; do
usage 0
;;
*)
echo "Unknown option: $1" >&2
usage
usage_error "unknown option: $1"
;;
esac
done
@@ -12,13 +12,23 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
OUTPUT_FILE=""
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-metadata.sh -n <pr_number> [-o <output_file>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-o|--output)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
OUTPUT_FILE="$2"
shift 2
;;
@@ -29,18 +39,18 @@ while [[ $# -gt 0 ]]; do
echo " -n, --number PR number (required)"
echo " -o, --output Output file (optional, prints to stdout if omitted)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)" >&2
exit 1
usage_error "PR number is required (-n/--number)"
fi
write_metadata() {
@@ -39,6 +39,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
ACTION=""
COMMENT=""
BODY_FILE=""
LOGIN_OVERRIDE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
@@ -71,6 +72,12 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
--body-file)
# R3: body from file (or '-' = stdin); mutually exclusive with --body.
[[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)"
BODY_FILE="$2"
shift 2
;;
-l|--login)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LOGIN_OVERRIDE="$2"
@@ -108,6 +115,19 @@ while [[ $# -gt 0 ]]; do
esac
done
# R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-');
# exclusive with an explicit --body/--comment value.
if [[ -n "$BODY_FILE" ]]; then
[[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive"
if [[ "$BODY_FILE" == "-" ]]; then
COMMENT=$(cat) || usage_error "could not read body from stdin"
else
[[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE"
COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE"
fi
fi
if [[ -z "$PR_NUMBER" ]]; then
usage_error "PR number is required (-n/--number)"
fi
+14 -4
View File
@@ -11,13 +11,23 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
REPO_OVERRIDE=""
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-view.sh -n <pr_number> [-r owner/repo] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
@@ -28,18 +38,18 @@ while [[ $# -gt 0 ]]; do
echo " -n, --number PR number (required)"
echo " -r, --repo Repository slug (default: infer from git origin)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)"
exit 1
usage_error "PR number is required (-n/--number)"
fi
if [[ -n "$REPO_OVERRIDE" ]]; then
@@ -161,6 +161,30 @@ rc=0
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
# R3 body-file arms (2026-08-29): --body-file <path> and '-' (stdin).
BF_FILE="$WORK_DIR/body.md"
printf 'line one\nline two\n' > "$BF_FILE"
# File loads the body: parse acceptance then credential-class failure
# (sandboxed runner: rc nonzero and NOT 2).
rc=0
run_wrapper_sandboxed -i 5 --body-file "$BF_FILE" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "body-file arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "body-file arm misclassified credential failure as a usage error"
# Stdin form loads the body the same way.
rc=0
printf 'from stdin' | run_wrapper_sandboxed -i 5 --body-file - >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 && "$rc" -ne 2 ]] || fail "body-file stdin arm rc=$rc (want nonzero, not 2)"
# Mutually exclusive with --body: rc 2.
expect_rc 2 "body-file + body exclusive" -i 5 --body-file "$BF_FILE" -b explicit
expect_stderr "mutually exclusive" "exclusivity message on stderr"
# Missing file: rc 2 naming the path.
expect_rc 2 "missing body file" -i 5 --body-file "$WORK_DIR/nope.md"
expect_stderr "not readable" "missing-file message on stderr"
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
# invocation is the only permitted entry in the probe log).
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for pr-diff.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-diff-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-diff.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-diff.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-diff.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -o -r --number --output --repo --host; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n --help
expect_rc 2 "short flag value rejected" -n -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -n; do
rc=0
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-diff.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Usage-error contract for pr-list.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-list-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-list.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-list.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-list.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -s -l -a -n -r --state --label --author --limit --repo; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -s --help
expect_rc 2 "short flag value rejected" -s -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -s; do
rc=0
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-list.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for pr-metadata.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-metadata.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-metadata.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-metadata.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -o --number --output; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n --help
expect_rc 2 "short flag value rejected" -n -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -n; do
rc=0
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-metadata.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for pr-view.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-view-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-view.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-view.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-view.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -r --number --repo; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n --help
expect_rc 2 "short flag value rejected" -n -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -n; do
rc=0
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-view.sh usage-contract regression passed (R1/R4)"
@@ -35,6 +35,9 @@
#
# OPTIONS
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
#
# Exit 4: local target session exists on multiple socket servers and no
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
# -s DST_SESSION target tmux session (or session:window.pane) [required]
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
# -n DST_HOST hostname to show in the preamble for the target.
@@ -63,8 +66,10 @@
# group 1 = src label group 2 = dst host:session
# group 3 = class (absent => actionable) group 4 = message body
#
# EXIT CODES (passed through from send-message.sh)
# EXIT CODES (passed through from send-message.sh, except 4)
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
# 4 agent-send refusal: local target session exists on multiple socket
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
set -uo pipefail
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -154,6 +159,61 @@ FULL="${PREAMBLE} ${MSG}"
B64=$(printf '%s' "$FULL" | base64 -w0)
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
# Exact session matching for the sender target (codex PR #1466): without
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
# aimed at session X can land in X-old. Compound targets (session:win.pane)
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
# socket discovery so the discovery probes use the same target semantics
# (probing '==name' for an already-exact input was a false-negative hit).
DST_TARGET="$DST_SESSION"
case "$DST_SESSION" in
=*) ;;
*:*)
# Compound target (session:win.pane): pin the SESSION component exact
# (=session:win.pane); unpinned, the session part still prefix-matches
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
;;
*) DST_TARGET="=$DST_SESSION" ;;
esac
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
# no explicit or env choice exists and the session name lives on multiple
# servers (measured 2026-08-28/29: tasking sends landed in a stale
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
# leak into remote sends, where -L would target a socket on the remote
# host (codex PR #1466).
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
fi
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
hits=""
for sf in "$socket_dir"/*; do
[ -S "$sf" ] || continue
sname="${sf##*/}"
# '=' forces exact session-name matching: tmux target syntax otherwise
# accepts an unambiguous PREFIX, so a session named X-old on a socket
# would count as a false hit for target X (codex PR #1466).
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
done
hit_count=$(printf '%s' "$hits" | grep -c . || true)
if [ "$hit_count" -gt 1 ]; then
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
printf ' %s\n' $hits >&2
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
exit 4
elif [ "$hit_count" -eq 1 ]; then
SOCKET_NAME="$(printf '%s' "$hits")"
fi
fi
socket_args=()
if [ -n "$SOCKET_NAME" ]; then
socket_args=(-L "$SOCKET_NAME")
@@ -161,9 +221,9 @@ fi
if [ -z "$SSH_TARGET" ]; then
# Local pane: call the canonical sender directly.
exec "$SENDER" "${socket_args[@]}" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
else
# Remote pane: ship the sender over ssh and run it local to the target.
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
"bash -s -- ${socket_args[*]@Q} -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
fi
@@ -8,7 +8,15 @@ SOCKET="mosaic-test-$RANDOM-$$"
TARGET="target-$RANDOM"
DEFAULT_TARGET="default-target-$RANDOM"
TMPDIR=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
ART_OUT=$(mktemp)
AMB_OUT=$(mktemp)
AMB_ERR=$(mktemp)
A2_OUT=$(mktemp)
A2_ERR=$(mktemp)
UNIQ_OUT=$(mktemp)
UNIQ_ERR=$(mktemp)
TWIN="twin-$RANDOM-$$"
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true; rm -rf "$TMPDIR" $ART_OUT $AMB_OUT $AMB_ERR $A2_OUT $A2_ERR $UNIQ_OUT $UNIQ_ERR' EXIT
fail() {
echo "FAIL: $*" >&2
@@ -79,4 +87,83 @@ for i in $(seq 1 "$CONC_N"); do
done
done
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
# defect: tasking sends without -L landed in a stale default-socket twin of
# the target seat; rc 0 reported honest delivery to the wrong pane.
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
amb_rc=0
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
# the env var disambiguates by precedence (codex PR #1466 blocker).
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
a2_rc=0
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
sleep 0.2
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
if grep -qF "env disambiguated" <<<"$a2_default"; then
fail "env disambiguation cross-delivered to the default-socket twin"
fi
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
# and deliver there.
# Arm A3: prefix matching must not produce false socket hits (codex PR
# #1466): a session named TWIN-old must not count as a hit for target
# TWIN (tmux target syntax prefix-matches without '=').
PSEUDO="${TWIN}-old"
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
A3_ERR=$(mktemp)
a3_rc=0
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
# PSEUDO session is not a hit, so the sender must fail target-not-found
# (rc 1) instead of delivering into the prefix-named session.
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
a3_pane="$(tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null)" || a3_pane=""
if printf '%s' "$a3_pane" | grep -F "prefix trap" >/dev/null; then
fail "delivery landed in the prefix-named session (false socket hit)"
fi
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
rm -f "$A3_ERR"
# Arm A4: compound targets pin the SESSION component exact (codex PR
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
PSEUDO2="${TWIN}-old"
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
A4_ERR=$(mktemp)
a4_rc=0
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
a4_pane="$(tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null)" || a4_pane=""
if printf '%s' "$a4_pane" | grep -F "compound trap" >/dev/null; then
fail "compound delivery landed in the prefix-named session"
fi
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
rm -f "$A4_ERR"
uniq_rc=0
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
sleep 0.2
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
fail "auto-resolution cross-delivered to the default socket pane"
fi
echo "ok - named tmux socket send tools"
File diff suppressed because one or more lines are too long
@@ -15,6 +15,17 @@ import { afterEach, describe, expect, it } from 'vitest';
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
describe('arg translation', () => {
// B2 (2026-08-29): when --socket is omitted, the CLI forwards NO -L and
// agent-send.sh's own resolution governs (explicit -L > MOSAIC_TMUX_SOCKET
// > unique hit > ambiguity refusal). Forwarding a guessed default here
// would defeat that resolution and reintroduce the stale-twin defect.
it('omits -L entirely when --socket is not given (tool-owned resolution)', () => {
const args = tmuxSendArgs('orch-01', 'hello', {});
expect(args).toEqual(['-s', 'orch-01', '-m', 'hello']);
expect(args).not.toContain('-L');
expect(args.join(' ')).not.toContain('-L');
});
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
expect(
+4 -1
View File
@@ -62,7 +62,10 @@ export function registerCommsCommand(program: Command): void {
.description('send <target> [message...] — same-host tmux unless --site is given')
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
.option('--file <path>', 'message body from file (same-host path only)')
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
.option(
'--socket <name>',
'tmux socket for the same-host send (e.g. mosaic-fleet). OMIT it to let agent-send.sh resolve the socket (explicit -L > MOSAIC_TMUX_SOCKET > unique hit; ambiguity refuses with its exit 4).',
)
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
.argument('<target>', 'destination seat (session name)')
@@ -106,6 +106,7 @@ describe('registerFleetCommand', () => {
'init',
'install',
'install-systemd',
'logins',
'migrate-v1',
'persona',
'plan',
+19 -1
View File
@@ -17,7 +17,7 @@ import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import * as readline from 'node:readline';
import type { Command } from 'commander';
import YAML from 'yaml';
@@ -1606,6 +1606,24 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
.option('--roster <path>', 'Fleet roster path');
// fleet logins (P4 gap closure, 2026-08-29): project per-seat git tokens
// from the brain into usable credentials. The implementation is the
// framework fleet tool; args pass through verbatim (--apply --seat X).
cmd
.command('logins')
.description('Per-seat git credentials: seat-logins.sh pass-through (--apply --seat <name>)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((...rest: unknown[]) => {
const cmdObj = rest[rest.length - 1] as { args: string[] };
const script = `${frameworkRoot}/tools/fleet/seat-logins.sh`;
const result = spawnSync('bash', [script, ...cmdObj.args], {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 125);
});
cmd
.command('init')
.description('Initialize a local fleet roster')