@sema-agent/sdk 6.11.0 → 6.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -135,6 +135,24 @@ tags:
135
135
  MF-Fleet (shell-host data contract) — the LIVE multi-row fleet view SSE (active task + workflow rows,
136
136
  transitioning over time). Owner-scoped by the credential principal (scope wire-stripped) unless fleet-wide;
137
137
  in-process bus, NOT resumable (a drop is a full restart). No fleet wiring → 501.
138
+ - name: rules
139
+ description: >
140
+ #154 车二 (design/179 §7) — PERSISTENT permission rules: the CC-settings import lane (prepare → redeem).
141
+ Two-step consent protocol: prepare only previews and mints a one-shot, principal-bound, expiring,
142
+ payload-bound ticket; only redeem reaches the writer. LANE = PRINCIPAL (a rule means "this person
143
+ themself said yes"), never operator. GATED AND OFF BY DEFAULT: the whole lane hangs on
144
+ PERMISSION_RULES_ENABLED (default false) plus a wired rule store — so on a stock deployment BOTH doors
145
+ answer 501 `capability.rule_store_required`, and so does the single-card half (the ask frame simply
146
+ carries no `ruleSuggestions`). Read the observed frame / an actual response, never a version number.
147
+ The SINGLE-CARD half of the same lane is `persistRule` on POST /v1/tool-approvals/{id}/respond.
148
+ - name: adoption
149
+ description: >
150
+ design/183 — identity ADOPTION (form b: pure identity rebind). A one-shot, operator-only DEPLOYMENT
151
+ action; nothing billed. Idempotent in the sense that re-running with the same parameters does not start a
152
+ second adoption and, ONCE TERMINAL, replays the SAME FROZEN `immutableReport` byte-for-byte — the receipt
153
+ as a whole carries no such guarantee (`current` is recomputed every read, and a `stalled` report is a
154
+ provisional projection). Requires a SQL backend (→ else 501). Refusals never produce a receipt — they are
155
+ typed 409s. ⚠️ The GET does not sweep late rows but CAN resume an in-flight arc — it is not a safe poll.
138
156
  - name: leader
139
157
  description: v2 leader pipeline (gated; E2B + git push). NOT the default door-B brain.
140
158
  - name: metrics
@@ -2530,6 +2548,67 @@ paths:
2530
2548
  '404': { $ref: '#/components/responses/NotFound' }
2531
2549
  '501': { $ref: '#/components/responses/NotImplemented' }
2532
2550
 
2551
+ /v1/tasks/{taskId}/tool-results/{ref}:
2552
+ parameters:
2553
+ - $ref: '#/components/parameters/PrincipalHeader'
2554
+ - $ref: '#/components/parameters/TaskIdPath'
2555
+ - in: path
2556
+ name: ref
2557
+ required: true
2558
+ schema: { type: string }
2559
+ description: >
2560
+ The opaque tool-result ref the model was handed in the offload preview
2561
+ (`<persisted-output ref="tr_…">`). Pass it VERBATIM (URL-encoded) — never re-derive it.
2562
+ get:
2563
+ tags: [trace]
2564
+ operationId: traceToolResult
2565
+ x-status: live # server: routes/trace-usage.ts `handleToolResult` (blackboard [3321]).
2566
+ summary: Page back an offloaded (large) tool result.
2567
+ description: >
2568
+ The wire mirror of the engine's `ToolResultStore.get`: when a tool returns more text than the
2569
+ offload threshold, the conversation keeps a preview + a `ref` and the full text moves into the
2570
+ deployment's durable tool-result store. This endpoint reads a SLICE of it back, so a workspace/
2571
+ shell can show the full output the model paged through.
2572
+
2573
+ 🔴 The 404 is DELIBERATELY uniform (`not_found.tool_result`, one fixed message, the ref never
2574
+ echoed): a ref this task's log does not attest (unknown, another session's, another task's), a
2575
+ deployment with no durable tool-result store, and an attested ref whose content was already
2576
+ reaped are byte-identical responses. Do not branch on them — you cannot tell them apart, by
2577
+ design (a distinguishable arm would be an existence/deployment-shape oracle). In particular this
2578
+ face never 501s on a missing store, and a 404 never proves the output is gone.
2579
+
2580
+ SCOPE — read the ref on the task whose trace showed it. Ownership is the trace family's gate (a
2581
+ principal caller sees only its own runs; a fleet credential is fleet-wide) PLUS an exact binding:
2582
+ the ref must be one this task's own durable event log attests (it is re-derived from the log's
2583
+ tool-call ids). The trace and this face read the same log, so "visible in the task's trace ⇒
2584
+ readable here" holds; a ref carried over from a DIFFERENT task of the same session is a 404 —
2585
+ ask for it under that task's id.
2586
+ parameters:
2587
+ - in: query
2588
+ name: offset
2589
+ required: false
2590
+ schema: { type: integer, minimum: 0, maximum: 2147483646 }
2591
+ description: >
2592
+ Char offset to start the slice at. Absent ⇒ the store's default (0). The cap is the narrowest
2593
+ backend's integer range (the PostgreSQL store binds `offset + 1` as int4) — above it the server
2594
+ answers 400 rather than letting the read fail backend-dependently.
2595
+ - in: query
2596
+ name: limit
2597
+ required: false
2598
+ schema: { type: integer, minimum: 0, maximum: 262144 }
2599
+ description: >
2600
+ Max chars to return. Absent ⇒ the store's default. Above 262144 ⇒ 400 (the server refuses
2601
+ rather than silently clamping, so a caller can never mistake a truncated slice for the ask).
2602
+ responses:
2603
+ '200':
2604
+ description: One slice of the stored content (`totalChars` is the FULL length, not the slice's).
2605
+ content:
2606
+ application/json:
2607
+ schema: { $ref: '#/components/schemas/ToolResultSlice' }
2608
+ '400': { $ref: '#/components/responses/BadRequest' } # request.query_invalid — offset/limit not a non-negative integer, or limit over the cap
2609
+ '401': { $ref: '#/components/responses/Unauthorized' }
2610
+ '404': { $ref: '#/components/responses/NotFound' } # not_found.tool_result — the uniform arm (see description)
2611
+
2533
2612
  /v1/tasks/{taskId}/stream:
2534
2613
  parameters:
2535
2614
  - $ref: '#/components/parameters/PrincipalHeader'
@@ -2806,6 +2885,32 @@ paths:
2806
2885
  properties:
2807
2886
  decision: { type: string, enum: [allow, allow_session, deny] }
2808
2887
  updatedInput: { description: 'ctrl+g edited tool args (replaces the asked args on allow).' }
2888
+ persistRule:
2889
+ type: object
2890
+ # 刻意**不**封闭:server 的 `parseToolApprovalResponse` 只读 `rule`,对象里多给的键它**忽略**
2891
+ # (与 `updatedInput` 同一条宽收姿势)。写 `additionalProperties:false` 会让照 spec 生成的
2892
+ # 客户端拒发一个 server 其实会受理的请求 —— 那是我们自己造的漂移,不是上游的约束。
2893
+ required: [rule]
2894
+ properties:
2895
+ rule: { type: string, minLength: 1, maxLength: 512, description: 'Non-empty (the server parser rejects an empty string); at most MAX_RULE_TEXT_CHARS = 512.' }
2896
+ description: >
2897
+ server #154 车二, ADDITIVE — the person ticked "don't ask again": echo back, VERBATIM, the
2898
+ `rule` of the chosen entry of the frame's `ruleSuggestions`. Allow-family only (on `deny`
2899
+ the server tolerantly ignores it, same posture as `updatedInput`).
2900
+
2901
+ 🔴 A CANDIDATE, NOT FREE TEXT: the server locates this string in the candidate table the
2902
+ engine minted and refuses if it does not match (`ruleRefusal: "rule_not_offered"`). Do not
2903
+ hand-build it, re-case it or append `:*` — take it from the frame.
2904
+ 🔴 Together with `updatedInput` the persistence is refused (`rule_input_edited`): the call
2905
+ that takes effect is the edited one while the candidates came from the ORIGINAL command.
2906
+ The decision itself still stands.
2907
+ ⚠️ If the frame carried no `ruleSuggestions`, do not send this key — there is nothing
2908
+ redeemable for THIS card and the answer is necessarily `rulePersisted:false` +
2909
+ `rule_lane_unavailable` (the missing material short-circuits BEFORE any candidate
2910
+ comparison, so it is never `rule_not_offered`). Absence has THREE possible causes — no rule
2911
+ store wired, the engine minted no candidate for this command (compound / redirect /
2912
+ substitution), or the command bytes were unreadable — so do NOT diagnose the deployment's
2913
+ rule lane from one frame or from that refusal.
2809
2914
  responses:
2810
2915
  '200':
2811
2916
  description: Ack (`decision` echoed).
@@ -3855,6 +3960,242 @@ paths:
3855
3960
  schema: { $ref: '#/components/schemas/ErrorResponse' }
3856
3961
  '501': { $ref: '#/components/responses/NotImplemented' }
3857
3962
 
3963
+ # ── #154 车二(design/179 §7)—— 持久权限规则的 CC settings 导入两口 ─────────────────────────────
3964
+ # 两步同意协议:prepare 产出预览 + 一张票(**零规则落库**,但 pending 记录与票本身是 durable 的 ⇒ 不幂等),
3965
+ # redeem 才够得着写面。lane = principal(用户导的是
3966
+ # 他自己的规则),NOT operator。整条车道挂 PERMISSION_RULES_ENABLED,未接规则店 ⇒ 两口 501。
3967
+ /v1/rules/cc-import/prepare:
3968
+ parameters:
3969
+ - $ref: '#/components/parameters/PrincipalHeader'
3970
+ post:
3971
+ tags: [rules]
3972
+ operationId: rulesCcImportPrepare
3973
+ x-status: gated # server routes/rules.ts;PERMISSION_RULES_ENABLED + 规则店装配,缺一 ⇒ 501 capability.rule_store_required。SDK rules.ccImportPrepare() 消费。
3974
+ summary: Preview a CC-settings rule import and mint a one-shot redemption ticket.
3975
+ description: >
3976
+ Read the ALLOW buckets of 1–3 user-editable CC settings layers the caller submits, and answer with
3977
+ what the import WOULD do plus a ticket.
3978
+
3979
+ 🔴 NO PERMISSION RULE is persisted by this call — only POST /v1/rules/cc-import/redeem reaches the
3980
+ writer. But the call is NOT free of durable state and NOT idempotent: every successful prepare writes
3981
+ a PENDING approval record and mints ANOTHER valid ticket. So re-previewing in a loop accumulates
3982
+ pending records and live tickets; preview once, show it, then redeem or drop it. (A candidate-cap
3983
+ rejection tries to discard the record it just created, best-effort — a leftover orphan pending record
3984
+ affects no adjudication, but it is not a promise of zero residue either.)
3985
+
3986
+ Only the allow bucket is read. The deny/ask buckets are the TIGHTENING direction and have their own
3987
+ channel; importing them through a loosening lane would be the wrong door.
3988
+
3989
+ The ticket is principal-bound, expiring (10 min), single-use (atomically consumed) and payload-bound
3990
+ (it pins a digest of the candidate set at mint time). It is NOT a cacheable credential and must never
3991
+ be handed to another user.
3992
+
3993
+ LANE = PRINCIPAL, not operator: a rule means "this person themself said yes", so an operator has no
3994
+ position on this path — an operator importing their OWN rules walks the same route under their own
3995
+ principal.
3996
+ requestBody:
3997
+ required: true
3998
+ content:
3999
+ application/json:
4000
+ schema:
4001
+ type: object
4002
+ # 封闭:server 侧 zod 是 `.strict()` —— 多一个键是 400,不是静默忽略(把 `content` 拼成
4003
+ # `contents` 的客户端应当场知道,而不是拿到一份「零候选」的预览去纳闷)。
4004
+ additionalProperties: false
4005
+ required: [layers]
4006
+ properties:
4007
+ layers:
4008
+ type: array
4009
+ minItems: 1
4010
+ maxItems: 3
4011
+ items: { $ref: '#/components/schemas/CcImportLayer' }
4012
+ responses:
4013
+ '200':
4014
+ description: >
4015
+ What the import would do, plus the redemption ticket. NO PERMISSION RULE is in the store yet — but a
4016
+ pending approval record and this ticket are already durable (this call is not idempotent; see the
4017
+ operation description).
4018
+ content:
4019
+ application/json:
4020
+ schema: { $ref: '#/components/schemas/CcImportPrepareResult' }
4021
+ '400': { $ref: '#/components/responses/BadRequest' } # request.body_shape — not {layers:[{layer,path,root,content}]}, or an empty `root` (an empty prefix would silently promote a project rule to global)
4022
+ '401': { $ref: '#/components/responses/Unauthorized' }
4023
+ '413': { $ref: '#/components/responses/PayloadTooLarge' } # request.payload_too_large — more than 200 candidates across all submitted layers; SHORTEN and resend (retrying the same payload cannot help)
4024
+ '429': { $ref: '#/components/responses/RateLimited' }
4025
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.rule_store_required — this deployment has no permission-rule store wired
4026
+
4027
+ /v1/rules/cc-import/redeem:
4028
+ parameters:
4029
+ - $ref: '#/components/parameters/PrincipalHeader'
4030
+ post:
4031
+ tags: [rules]
4032
+ operationId: rulesCcImportRedeem
4033
+ x-status: gated # server routes/rules.ts;同 prepare 的部署谓词。SDK rules.ccImportRedeem() 消费。
4034
+ summary: Redeem a prepared import ticket — the ONE step that reaches the rule writer.
4035
+ description: >
4036
+ Atomically consume the ticket, confirm the pending approval record, and apply the batch. Idempotent
4037
+ at the engine level (each candidate carries its own dot), but the TICKET is single-use — do not
4038
+ auto-retry this call, and never replay it speculatively.
4039
+
4040
+ 🔴 THE 404 IS UNIFORM BY DESIGN (`not_found.rule_ticket`, one fixed message): a forged ticket,
4041
+ ANOTHER principal's ticket, an expired one and an already-consumed one are byte-identical responses.
4042
+ Distinguishable arms would be an existence oracle (probing whether someone else's ticket exists).
4043
+ ⚠️ It is also WIDER than those four: non-retryable record/payload failures (the approval record is gone,
4044
+ the payload digest no longer matches, an adjudicated confirmation refusal) fold into the SAME 404. So do
4045
+ NOT branch on the cause and do NOT read it as "the user brought the wrong ticket" — treat any 404 as
4046
+ "this ticket is not usable here, start over".
4047
+
4048
+ The ONE distinguishable arm is 503 `state.rule_import_retry`: it means the claim was RELEASED and
4049
+ the ticket is STILL USABLE — retry as-is after `retryAfterSec` (echoed in `Retry-After`). Folding
4050
+ that into the 404 would tell a caller their confirmation is lost when it is not; the two call for
4051
+ opposite handling.
4052
+ requestBody:
4053
+ required: true
4054
+ content:
4055
+ application/json:
4056
+ schema:
4057
+ type: object
4058
+ additionalProperties: false # server 侧 `.strict()`
4059
+ required: [ticket]
4060
+ properties:
4061
+ ticket: { type: string, minLength: 1, maxLength: 190, description: 'The ticket from rulesCcImportPrepare. Verbatim.' }
4062
+ responses:
4063
+ '200':
4064
+ description: What the import ACTUALLY did (a different moment and contract from the preview).
4065
+ content:
4066
+ application/json:
4067
+ schema: { $ref: '#/components/schemas/CcImportRedeemResult' }
4068
+ '400': { $ref: '#/components/responses/BadRequest' } # request.body_shape — body is not {ticket}
4069
+ '401': { $ref: '#/components/responses/Unauthorized' }
4070
+ '404': { $ref: '#/components/responses/NotFound' } # not_found.rule_ticket — the UNIFORM arm (forged / another principal's / expired / already used). Never branch on the cause.
4071
+ '429': { $ref: '#/components/responses/RateLimited' }
4072
+ '503':
4073
+ description: >
4074
+ errorCode `state.rule_import_retry` — the import could not complete right now (a transient store
4075
+ wobble), the claim was released and THE TICKET IS STILL USABLE. Body carries `retryAfterSec`
4076
+ and the same value rides `Retry-After`. Retry the SAME ticket. This is the only arm on this
4077
+ endpoint a caller may distinguish from the uniform 404, and the distinction is load-bearing.
4078
+ headers:
4079
+ Retry-After:
4080
+ schema: { type: integer }
4081
+ description: Seconds to wait before retrying with the same ticket.
4082
+ content:
4083
+ application/json:
4084
+ schema: { $ref: '#/components/schemas/ErrorResponse' }
4085
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.rule_store_required
4086
+
4087
+ # ── design/183 —— 身份收编面(form b:纯身份重绑)。operator-only 的一次性部署级动作,零模型工作。 ──
4088
+ /v1/adoption:
4089
+ parameters:
4090
+ - $ref: '#/components/parameters/PrincipalHeader'
4091
+ post:
4092
+ tags: [adoption]
4093
+ operationId: adoptionStart
4094
+ x-status: gated # server routes/adoption.ts;需 SQL 后端(DB_BACKEND=mysql|pg),否则 501 capability.adoption_store_required。SDK adoption.start() 消费。
4095
+ summary: Adopt one principal's data under another principal (identity rebind, form b).
4096
+ description: >
4097
+ A one-shot DEPLOYMENT action, not a business surface: operator-only, nothing billed, and honestly 501
4098
+ on a deployment with no SQL backend (the identity axis being rebound does not exist on the local one).
4099
+
4100
+ IDEMPOTENT: re-running with the same parameters never starts a second adoption, and ONCE THE ARC IS
4101
+ TERMINAL it replays the same frozen `immutableReport` byte-for-byte (written once at the final phase,
4102
+ never recomputed). The RECEIPT AS A WHOLE carries no byte-identity guarantee — `current` is recomputed
4103
+ on every call, so two reads may match or may differ — and before the terminal state
4104
+ (`status: "stalled"`) the report itself is a provisional projection of the current row. Concurrent identical POSTs are
4105
+ arbitrated by a UNIQUE on the source principal: exactly one row lands and both connections read the
4106
+ same `adoptionId`.
4107
+
4108
+ 🔴 A REFUSED ADOPTION NEVER PRODUCES A RECEIPT. `status` is a two-word closed set
4109
+ (`adopted`/`stalled`) with NO `rejected` member — refusals are typed 409s. Otherwise "refused" and
4110
+ "succeeded" would share one 200 envelope and a consumer could only guess by reading fields.
4111
+
4112
+ 🔴 This does NOT fence off ordinary writers. "Stop the engine first" is an OPERATOR PRECONDITION, not
4113
+ a machine guarantee: a caller still injecting the old identity keeps writing rows after the terminal
4114
+ state, while the idempotent short-circuit keeps answering `adopted`. That is what
4115
+ `current.residualSourceRows` makes visible — `> 0` means rows MAY still sit under the old identity OR
4116
+ the count itself failed (the server reports 1 fail-closed when it cannot count); either way, go look.
4117
+ It is not proof of an active writer. A POST (a write verb) also sweeps those late rows into the new
4118
+ identity and re-reports the count; a GET does not sweep — but it DOES resume an in-flight arc (see the
4119
+ GET description), so it is not a safe poll either.
4120
+ requestBody:
4121
+ required: true
4122
+ content:
4123
+ application/json:
4124
+ schema: { $ref: '#/components/schemas/AdoptionRequest' }
4125
+ responses:
4126
+ '200':
4127
+ description: >
4128
+ The adoption receipt — the SAME ENVELOPE SHAPE for the first success and for every idempotent
4129
+ re-run. There is NO byte-identity guarantee for the whole receipt (it may match or differ):
4130
+ `current` is recomputed on every call. The one frozen part is a TERMINAL `immutableReport`.
4131
+ content:
4132
+ application/json:
4133
+ schema: { $ref: '#/components/schemas/AdoptionReceipt' }
4134
+ '400': { $ref: '#/components/responses/BadRequest' } # request.body_shape / request.field_invalid — same from/to ("nothing to adopt", refused BEFORE a row is minted so the UNIQUE slot is not burned), or a principal that does not fit every identity column and derived key this adoption must write
4135
+ '401': { $ref: '#/components/responses/Unauthorized' }
4136
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.operator_only
4137
+ '409':
4138
+ description: >
4139
+ A refusal — THREE distinct errorCodes ride this status and the operator's next action differs for
4140
+ each, so branch on `errorCode`, never on the status:
4141
+ `adoption.source_already_bound` (this source is already bound to a DIFFERENT destination — a
4142
+ second adoption / cross-tenant transfer, deliberately out of scope; body carries `adoptionId`,
4143
+ `fromPrincipal`, `boundTo`);
4144
+ `adoption.destination_conflict` (the destination already holds rows sharing a logical key with
4145
+ the source; body lists the conflicting tables in `conflicts` — go clean those up);
4146
+ `adoption.destination_unrepresentable` (the destination identity does not fit some key this
4147
+ adoption would DERIVE from it, e.g. a memory `proj:` key overrunning a column width — pick a
4148
+ SHORTER destination principal).
4149
+ Either way both sides are byte-unchanged.
4150
+ content:
4151
+ application/json:
4152
+ schema: { $ref: '#/components/schemas/ErrorResponse' }
4153
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.adoption_store_required — adoption requires a SQL store backend (DB_BACKEND=mysql|pg)
4154
+
4155
+ /v1/adoption/{adoptionId}:
4156
+ parameters:
4157
+ - $ref: '#/components/parameters/PrincipalHeader'
4158
+ - { name: adoptionId, in: path, required: true, schema: { type: string }, description: 'From AdoptionReceipt.adoptionId.' }
4159
+ get:
4160
+ tags: [adoption]
4161
+ operationId: adoptionGet
4162
+ x-status: gated # server routes/adoption.ts;同 POST 的部署谓词。SDK adoption.get() 消费。
4163
+ summary: Read one adoption's state (and RESUME it if it is still in flight).
4164
+ description: >
4165
+ 🔴 THIS IS NOT A SIDE-EFFECT-FREE READ. On a TERMINAL row (adopted / refused) it is a pure read. On a
4166
+ row that is STILL IN FLIGHT — the typical case being a deployment where nothing has POSTed again since
4167
+ a crash — it RESUMES THE ARC: the same `driveUnderLock` the boot scan uses, advancing phases, rewriting
4168
+ rows on the identity axis, recording the config ledger and landing the terminal state. That is
4169
+ deliberate upstream (otherwise one crash would require a human to re-POST), but a consumer must know
4170
+ it: do NOT treat this as a safe prefetch / dashboard poll — reading an in-flight arc is pressing
4171
+ continue on it. The ONLY difference from the POST is that the GET does NOT sweep late rows into the
4172
+ new identity (the POST does). Repeating the call is safe (advisory lock + phase CAS + idempotent
4173
+ identity ⇒ a replay lands where one call lands) — but "safely replayable" is not "read-only". There
4174
+ is no verb on this wire today that guarantees zero progression.
4175
+
4176
+ `immutableReport` is historical fact — but only ONCE `status` is `adopted`; on a `stalled` receipt it is
4177
+ a provisional projection of the current row and can differ between two reads (see the AdoptionReport
4178
+ schema). `current` is recomputed every time. Do not infer one half from the other — in particular
4179
+ `status: "adopted"` does NOT mean the configuration migration is done; that account lives in
4180
+ `current.outstandingConfigs` / `current.residualSourceRows`, and a non-zero residual may also mean the
4181
+ count itself failed (see that field).
4182
+ responses:
4183
+ '200':
4184
+ description: The adoption receipt.
4185
+ content:
4186
+ application/json:
4187
+ schema: { $ref: '#/components/schemas/AdoptionReceipt' }
4188
+ '400': { $ref: '#/components/responses/BadRequest' } # request.id_invalid — undecodable id (shared with sessions/attachments; no second synonym code was minted)
4189
+ '401': { $ref: '#/components/responses/Unauthorized' }
4190
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.operator_only
4191
+ '404': { $ref: '#/components/responses/NotFound' } # not_found.adoption — no such id. ⚠️ A REFUSED adoption is still a typed 409 here, not a 404: the refusal is remembered.
4192
+ '409':
4193
+ description: 'Same three refusal codes as adoptionStart (a refused adoption keeps answering its refusal).'
4194
+ content:
4195
+ application/json:
4196
+ schema: { $ref: '#/components/schemas/ErrorResponse' }
4197
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.adoption_store_required
4198
+
3858
4199
  /metrics/summary:
3859
4200
  get:
3860
4201
  tags: [metrics]
@@ -4927,8 +5268,12 @@ components:
4927
5268
  type: object
4928
5269
  description: >
4929
5270
  Trace turn (true shape, service-pinned). role currently only "assistant" (user/system/tool reserved —
4930
- the user/objective opening turn is NOT projected today). blocks is an OPEN set; tool-result.output
4931
- is currently ALWAYS absent (session-storage join not wired — known gap).
5271
+ the user/objective opening turn is NOT projected today). blocks is an OPEN set. tool-result.output IS
5272
+ present (server trace/project.ts projects it; the old "ALWAYS absent" note was stale — [3315] finding) —
5273
+ but note it carries the STORED form: outputs over the engine's offload threshold are persisted in
5274
+ bounded form (head/tail preview + `<persisted-output ref>` marker), so `output` here can be the
5275
+ truncated form, disclosed via `truncated`/`totalChars`. Full retrieval of an offloaded output awaits
5276
+ the tool-results read face ([3316] server half, planned).
4932
5277
  required: [seq, ts, role, blocks]
4933
5278
  additionalProperties: true
4934
5279
  properties:
@@ -4968,6 +5313,9 @@ components:
4968
5313
  # 于是「server 少发 retainedFrom」这种断页判据丢失对本门不可见。
4969
5314
  additionalProperties: false
4970
5315
  required: [turns, retainedFrom]
5316
+ # ⚠️ 路别差异契约([3315]② 定谳,core [3316] 判设计事实非缺陷):trace 记账只在 /v1/runs 路;
5317
+ # sync `POST /v1/tasks` 完成后的 turns 读面对该 run 恒 `turns: []`(retainedFrom: 0)。这是契约,
5318
+ # 不是待修缺口——sync 路要 trace 请改走 /v1/runs。哪天 sync 路开始填 turns = 行为变更需过本表。
4971
5319
  properties:
4972
5320
  turns:
4973
5321
  type: array
@@ -5048,6 +5396,21 @@ components:
5048
5396
  type: array
5049
5397
  items: { $ref: '#/components/schemas/Artifact' }
5050
5398
 
5399
+ ToolResultSlice:
5400
+ type: object
5401
+ description: >
5402
+ One slice of an offloaded tool result (`GET /v1/tasks/{taskId}/tool-results/{ref}`) — the wire
5403
+ form of the engine's `ToolResultSlice`. `offset` echoes where this slice starts (so successive
5404
+ reads concatenate deterministically) and `totalChars` is the length of the FULL stored content,
5405
+ independent of the slice — a client pages until `offset + content.length === totalChars`.
5406
+ # 封闭:铸造点 routes/trace-usage.ts `handleToolResult` 只发这三键(core ToolResultSlice 原形)。
5407
+ additionalProperties: false
5408
+ required: [content, offset, totalChars]
5409
+ properties:
5410
+ content: { type: string, description: 'The slice text.' }
5411
+ offset: { type: integer, description: 'Char offset this slice starts at.' }
5412
+ totalChars: { type: integer, description: 'Total chars of the FULL stored content (not this slice).' }
5413
+
5051
5414
  LeaderReceipt:
5052
5415
  type: object
5053
5416
  description: >
@@ -5186,6 +5549,25 @@ components:
5186
5549
  legacy verbs in 1.0.0; the live face is `client.memory.exportScope` / `.sync`
5187
5550
  (`GET /v1/memory/export`, `POST /v1/memory/sync/{scope}`), which this flag does NOT gate.
5188
5551
  Kept on the wire so an older shell that still reads it honestly hides the dead affordances.
5552
+ adoption:
5553
+ type: boolean
5554
+ description: >
5555
+ design/183 form b adoption face (POST /v1/adoption + GET /v1/adoption/:id, operator lane) is
5556
+ wired. Same predicate as the routes' 501 (the SQL backend's adoptionLog factory is present;
5557
+ the local backend deliberately omits it). Probe before use, no trial-by-501.
5558
+ permissionRules:
5559
+ type: boolean
5560
+ description: >
5561
+ The permission-rule lane (/v1/rules/cc-import/* plus the respond persistRule redemption arm)
5562
+ is available. Server predicate = PERMISSION_RULES_ENABLED=true AND the rule store is wired
5563
+ (default OFF => false; the revocation face is a prerequisite for enabling).
5564
+ sharedMemory:
5565
+ type: boolean
5566
+ description: >
5567
+ design/177 org shared-memory read face (`GET /v1/shared-memory/*`) is mounted. Server-side the
5568
+ predicate is the SAME conjunction that mounts the route domain (SQL store present AND the
5569
+ principal→org membership fold present), so true ⟺ the face actually works — a deployment with
5570
+ the store but no membership authority deliberately reports false (and does not mount the routes).
5189
5571
  workflows: { type: boolean, description: "ENGINE-CAN: this worker's engine can orchestrate S8 self-orchestration workflows (server: `workflowsCapable ?? Boolean(workflowRunStore)`). The MOUNTING of the durable /v1/workflows read routes is the separate `workflowsList` bit below; the two coincide in today's wiring but are declared as orthogonal axes." }
5190
5572
  workflowsList: { type: boolean, description: "`GET /v1/workflows` list face (separate from the per-run reads)." }
5191
5573
  resumeAt: { type: boolean, description: "E18 resume-at-message anchors are resolvable." }
@@ -6025,6 +6407,8 @@ components:
6025
6407
  status: { type: string, description: 'the TaskResult''s terminal status (open vocabulary; core enum verbatim).' }
6026
6408
  error: { type: string, description: 'first line of errorMessage, clipped to 300 chars (redacted). Present only when the result carried an errorMessage.' }
6027
6409
  result: { type: string, description: 'the result text, clipped to 2000 chars (redacted). Present only when the result carried one.' }
6410
+ resultTruncated: { type: boolean, description: 'server >=7.9.0 (A-002.6): present (true) only when `result` was clipped at 2000 chars — the disclosure twin of the >64KiB whole-row truncated arm; absent = the result field is complete.' }
6411
+ resultChars: { type: integer, description: 'server >=7.9.0 (A-002.6): the ORIGINAL result length in characters; present only alongside resultTruncated.' }
6028
6412
  tokens: { type: integer, description: 'present only when the result carried stats.' }
6029
6413
  turns: { type: integer, description: 'present only when the result carried stats.' }
6030
6414
 
@@ -8148,6 +8532,44 @@ components:
8148
8532
  governance shell gate sits at the "classify" tier, a shell ask may come from the classifier or from
8149
8533
  another gate; the server leaves the key absent rather than guessing). Never render absence as
8150
8534
  "this gate can be bypassed by a posture".
8535
+ ruleSuggestions:
8536
+ type: array
8537
+ minItems: 1 # 空数组不可能出现:server 的合取项②要求 `req.ruleSuggestions` **非空**,否则整键省略(缺席 = 本卡无此选项)
8538
+ # 刻意**不**声明 maxItems:server 的 `.max(4)` 是**卡** schema(`ApprovalCardSchema`)上的约束,这条
8539
+ # live 帧腿没有它。在这里抄一个上界 = 声明一条 server 并不执行的约束(照 spec 生成的严格客户端会
8540
+ # 拒收一条合法帧)。数量上界的真源是引擎:core 5.22.0 的 `suggestRulesForCommand` **至多铸一条,且恒
8541
+ # `match: "exact"`**(不写死上界是给将来的 miner 留位,不是暗示今天会有更多)。
8542
+ items: { $ref: '#/components/schemas/RuleSuggestion' }
8543
+ description: >
8544
+ server #154 车二 (core 5.18.0, design/179), ADDITIVE, "tool_approval" only: the rule candidates
8545
+ the ENGINE minted for this ask. Render them as the "don't ask again" options and echo the chosen
8546
+ one back as `persistRule.rule` on POST /v1/tool-approvals/{id}/respond.
8547
+
8548
+ 🔴 PRESENCE IS THE PROMISE: the key is present ONLY on a deployment that actually has a
8549
+ permission-rule store wired (the same object behind the engine's `permissionRules.storeWired`
8550
+ manifest bit). A deployment without one OMITS it rather than offering an option with nowhere to
8551
+ redeem — an unredeemable "don't ask again" is a wire lie, worse than absence.
8552
+
8553
+ 🔴 THE CONVERSE DOES NOT HOLD: absence means "THIS CARD has no redeemable candidate", and three
8554
+ causes produce it — no rule store wired, the engine minted no candidate for this command (compound /
8555
+ redirect / substitution), or the command bytes were unreadable. One frame cannot tell them apart, so
8556
+ never diagnose the deployment's rule lane from a single absence, and never synthesize a candidate.
8557
+
8558
+ 📏 HOW MANY, TODAY: core 5.22.0's miner (`suggestRulesForCommand`) emits AT MOST ONE candidate and
8559
+ always `match: "exact"` — no shipped producer mints the broader prefix option. `prefix` stays in the
8560
+ closed vocabulary because the wire validator accepts it and a later miner may emit it; render what
8561
+ arrives, but do NOT build a UI that assumes a narrow/broad PAIR is on offer.
8562
+
8563
+ 🔴 The values are the engine's VERBATIM (closed `match` vocabulary + engine-minted text); the
8564
+ server neither re-mints nor re-orders them. A hand-built rule string is refused at redemption
8565
+ (`ruleRefusal: "rule_not_offered"`) — by construction, so a click on a harmless command cannot be
8566
+ turned into a rule for something else.
8567
+
8568
+ ⚠️ REDEMPTION SCOPE: the key also rides the durable card replay, but its only redemption channel is
8569
+ the LIVE respond leg keyed by this frame's `approvalId` — an in-process map on ONE replica. Same
8570
+ replica ⇒ redeemable; after a restart / on another replica that `approvalId` 404s and the person
8571
+ must use the durable decide leg, which carries NO rule field in v1 (its body is strict — an extra
8572
+ `persistRule` is a loud 400, not a silent drop).
8151
8573
  fromSubagent:
8152
8574
  type: boolean
8153
8575
  enum: [true]
@@ -8267,11 +8689,80 @@ components:
8267
8689
  posture. Semantics, the ABSENT != false clause and the discrimination boundary are stated verbatim on
8268
8690
  ToolApprovalFrame.governanceForced. It sits at the card's top level rather than inside `risk` because
8269
8691
  `risk` describes the ENGINE's judgement of the call, while this key says WHO imposed the gate.
8692
+ ruleSuggestions:
8693
+ type: array
8694
+ minItems: 1 # 同帧腿:空数组不可能出现(素材同源),缺席才是「本卡无此选项」
8695
+ maxItems: 4 # 卡形上**真有**这条上界(server `ApprovalCardSchema` 的 `.max(4)`);live 帧腿没有,故只写在这里
8696
+ items: { $ref: '#/components/schemas/RuleSuggestion' }
8697
+ description: >
8698
+ ADDITIVE (#154 车二 / core 5.18.0 design/179): the rule candidates the ENGINE minted for this ask —
8699
+ the card's "don't ask again" options; echo the chosen one back as `persistRule.rule`. Semantics,
8700
+ the PRESENCE-IS-THE-PROMISE clause (present only where a rule store is wired) and the redemption
8701
+ scope are stated verbatim on ToolApprovalFrame.ruleSuggestions — the live frame, the stored
8702
+ `card_json` and the replayed frame carry the SAME material.
8270
8703
  fromSubagent: { type: boolean, enum: [true], description: 'Present ⇔ the ask comes from a DELEGATED child — the explicit discriminator (a worker cannot forge it).' }
8271
8704
  sourceTaskId: { type: string, description: 'The child''s core session id — row-join value + fallback discriminator.' }
8272
8705
  sourceAgentName: { type: string, description: 'The child agent''s display name, redacted. UNTRUSTED.' }
8273
8706
  delegation: { $ref: '#/components/schemas/ApprovalCardDelegation' }
8274
8707
 
8708
+ RuleSuggestion:
8709
+ type: object
8710
+ description: >
8711
+ One ENGINE-minted rule candidate (#154 车二 / core 5.18.0 `RuleSuggestion`) — what a single click on
8712
+ "don't ask again" would persist.
8713
+
8714
+ 🔴 THE TEXT COMES FROM THE ENGINE, NOT THE SHELL: the server derives it from the adjudicated command's
8715
+ own bytes; a client may only echo one of these back verbatim (`persistRule.rule`). Reporting text that
8716
+ is not among the candidates is refused (`ruleRefusal: "rule_not_offered"`) rather than guessed at, so
8717
+ "click on a harmless command, get a rule for something else" is not spellable on this path.
8718
+ # 封闭:core `RuleSuggestion` 恰三键(rule/match/command),server 逐字透传不重铸。
8719
+ additionalProperties: false
8720
+ required: [rule, match, command]
8721
+ properties:
8722
+ rule: { type: string, maxLength: 512, description: 'The canonical rule text to echo back on redemption (server MAX_RULE_TEXT_CHARS = 512).' }
8723
+ match:
8724
+ type: string
8725
+ enum: [exact, prefix]
8726
+ description: '`exact` = this one command only (`Bash(git status)`); `prefix` = word-boundary prefix (`Bash(git status:*)`). Closed vocabulary — an unknown value is not a candidate this contract describes; do not render it as redeemable.'
8727
+ command: { type: string, maxLength: 512, description: 'The command pattern inside the rule, for a shell that would rather not re-parse `rule`.' }
8728
+
8729
+ RuleRefusalReason:
8730
+ type: string
8731
+ description: >
8732
+ Why a "don't ask again" did NOT get persisted (#154 车二; the server's `RuleRefusalReason`, verbatim).
8733
+ A CLOSED set so a shell can branch instead of matching prose. The two naming styles are NOT a typo:
8734
+ the four hyphenated members are derived from the consent lane's own refusal reasons, the four
8735
+ underscored ones are minted by the server's gates.
8736
+
8737
+ 🔴 A refusal NEVER flips the decision. The person's "allow this once" has already taken effect and
8738
+ reached the engine; turning the whole respond into a 4xx would make a real allow vanish. The honest
8739
+ shape is always 200 + `rulePersisted: false` + this reason.
8740
+
8741
+ `no-candidates` — the engine could mint no candidate for this command (compound/redirect/substitution).
8742
+ `unknown-candidate` — the echoed text does not locate in the record's candidate table.
8743
+ `confirm-refused` / `redeem-refused` — the consent protocol's confirm/redeem step was refused
8744
+ (record state, a lost CAS, or the store refusing the write).
8745
+ `rule_lane_unavailable` — FOUR causes share this one value (the server's three conjuncts): no rule store
8746
+ wired, OR the engine minted no candidate for this command, OR the command bytes were unreadable, OR this
8747
+ ask has no verified owner. 🔴 Two of those are per-CARD limits, so this reason is NOT evidence about the
8748
+ deployment — never disable the rule feature globally on the strength of it.
8749
+ `rule_input_edited` — the respond ALSO carried `updatedInput` (ctrl+g edit): the call that takes effect
8750
+ is the edited one while the candidates were minted from the ORIGINAL command, so persisting would arm a
8751
+ standing allow for the original (a real privilege widening). v1 refuses the persistence; the decision
8752
+ itself still stands.
8753
+ `rule_not_offered` — the echoed text is not among THIS ask's candidates (a stale card, or someone
8754
+ trying to mint a rule of their own).
8755
+ `rule_store_error` — the rule store wobbled. The adjudication is unaffected.
8756
+ enum:
8757
+ - no-candidates
8758
+ - unknown-candidate
8759
+ - confirm-refused
8760
+ - redeem-refused
8761
+ - rule_lane_unavailable
8762
+ - rule_input_edited
8763
+ - rule_not_offered
8764
+ - rule_store_error
8765
+
8275
8766
  ApprovalRiskAxes:
8276
8767
  type: object
8277
8768
  description: >
@@ -9585,7 +10076,10 @@ components:
9585
10076
  shape, `decision` echoed). Owner-gated with a 404 and NO existence oracle: non-owner and
9586
10077
  unknown/settled/expired/wrong-replica ids are indistinguishable.
9587
10078
  # 封闭(census 批2 第三段,2026-07-30):铸造点 `tool-approval.ts` 三恒在键 + 两条件键
9588
- # (rememberApplied/updatedInputForwarded 的条件展开就在同一行字面量里),键集恰是这 5 个。
10079
+ # (rememberApplied/updatedInputForwarded 的条件展开就在同一行字面量里)。
10080
+ # #154 车二(2026-08-10):`persistRuleAfterDecision` 的 `withFlag()` 在同一个体上再盖两个条件键
10081
+ # (rulePersisted 恒在、ruleRefusal 仅失败时)—— 键集从 5 升 7,**封闭性不动**(additive 不许把
10082
+ # census 闭合的口悄悄捅开)。
9589
10083
  required: [approvalId, delivery, decision]
9590
10084
  additionalProperties: false
9591
10085
  properties:
@@ -9604,6 +10098,24 @@ components:
9604
10098
  server 1.241 ([1458]): present (true) when an allow-family decision carried `updatedInput` and
9605
10099
  the server forwarded the edited args to the engine — forwarded, not necessarily applied
9606
10100
  (consumption depends on the core OnAsk object arm). Omitted on deny / no edit / older servers.
10101
+ rulePersisted:
10102
+ type: boolean
10103
+ description: >
10104
+ server #154 车二, ADDITIVE: whether the `persistRule` this respond carried actually landed in the
10105
+ rule store.
10106
+
10107
+ 🔴 A FAILED PERSISTENCE NEVER FLIPS THE DECISION — the person's "allow this once" already took
10108
+ effect and reached the engine, so the honest shape is 200 + `rulePersisted: false` + `ruleRefusal`,
10109
+ NOT a 4xx. Render it as "allowed, but 'don't ask again' was not saved" — never as a failed approval.
10110
+ Omitted when the respond carried no `persistRule`, and on older servers; ABSENT is not `false`.
10111
+ ruleRefusal:
10112
+ allOf: [{ $ref: '#/components/schemas/RuleRefusalReason' }]
10113
+ description: >
10114
+ server #154 车二, ADDITIVE: the MACHINE-READABLE reason when `rulePersisted` is false (closed
10115
+ vocabulary). Always absent when `rulePersisted` is true. Branch on it to separate "try again on the
10116
+ next ask" (`rule_input_edited`) from "there was nothing redeemable on this card"
10117
+ (`rule_lane_unavailable`). 🔴 `rule_lane_unavailable` covers four causes, two of them per-card — do
10118
+ NOT read it as "this deployment has no rule lane" (see the RuleRefusalReason schema).
9607
10119
 
9608
10120
  UsageMetric:
9609
10121
  type: string
@@ -9828,3 +10340,358 @@ components:
9828
10340
  path: { type: string, description: 'The relPath — pass VERBATIM as ?path= on the file sub-route.' }
9829
10341
  hash: { type: string, description: 'Content address (sha256) — enables cross-snapshot change highlighting ([1894]②).' }
9830
10342
  size: { type: integer, description: 'Present only when the store has a sizes face (SQL backends).' }
10343
+
10344
+ # ═══════════════════════════════════════════════════════════════════════════════════════════════
10345
+ # #154 车二(design/179 §7)—— 持久权限规则的 CC settings 导入面。形按 server 真码 + core 原形亲读
10346
+ # (`src/http/routes/rules.ts` / `src/rules-consent.ts`;preview/result 两形是 core `ImportPreview` /
10347
+ # `ImportResult` 的原样透传)。
10348
+ # ═══════════════════════════════════════════════════════════════════════════════════════════════
10349
+
10350
+ CcImportSettingsLayer:
10351
+ type: string
10352
+ enum: [userSettings, projectSettings, localSettings]
10353
+ description: >
10354
+ The three user-editable CC settings layers this version reads (core `ImportedSettingsLayer`). The two
10355
+ layers it does NOT read are named in `CcImportPreview.uncovered` rather than being silently absent.
10356
+
10357
+ RuleScope:
10358
+ description: >
10359
+ Where a persisted rule applies (core `RuleScope`). `project.root` is the directory prefix that decides
10360
+ which cwds the rule is live under.
10361
+ oneOf:
10362
+ - type: object
10363
+ additionalProperties: false
10364
+ required: [kind]
10365
+ properties:
10366
+ kind: { type: string, enum: [global] }
10367
+ - type: object
10368
+ additionalProperties: false
10369
+ required: [kind, root]
10370
+ properties:
10371
+ kind: { type: string, enum: [project] }
10372
+ root: { type: string, minLength: 1, description: 'The canonical directory this rule is scoped to. NEVER empty — an empty prefix contains EVERY cwd, which would silently promote a project rule to a global one.' }
10373
+
10374
+ RuleCandidate:
10375
+ type: object
10376
+ description: 'One candidate rule: the canonical text plus the scope it would land in (core `RuleCandidate`).'
10377
+ additionalProperties: false
10378
+ required: [rule, scope]
10379
+ properties:
10380
+ rule: { type: string, maxLength: 512 }
10381
+ scope: { $ref: '#/components/schemas/RuleScope' }
10382
+
10383
+ CcImportSkippedRule:
10384
+ type: object
10385
+ description: >
10386
+ One entry that did NOT become a candidate.
10387
+
10388
+ 🔴 `reason` IS HUMAN-READABLE PROSE, NOT A MACHINE CODE. Upstream produces exactly three shapes:
10389
+ a grammar/validation refusal is `"<RuleRejectCode>: <message>"` (code-PREFIXED prose, e.g.
10390
+ `"invalid.bare_interpreter_prefix: …"` — never the bare code); a layer whose JSON does not parse is
10391
+ `"settings file is not valid JSON (<error text>)"`; a non-string allow entry is
10392
+ `"settings entry is not a string"`. If you must classify, key on the prefix BEFORE the first `:` and
10393
+ handle the two unprefixed shapes — do not equality-match the whole string and do not expect a bare code.
10394
+
10395
+ ⚠️ `rule` is not always a rule text either: on the unparseable-JSON arm it carries THAT LAYER'S PATH
10396
+ (no entry was parsed, so there is no rule to name). Do not assume a `Bash(...)` shape when rendering.
10397
+ additionalProperties: false
10398
+ required: [rule, reason]
10399
+ properties:
10400
+ rule: { type: string }
10401
+ reason: { type: string }
10402
+
10403
+ CcImportLayerReport:
10404
+ type: object
10405
+ description: >
10406
+ Per-layer read report inside a preview.
10407
+
10408
+ ⚠️ ON THIS HTTP ROUTE `found` IS ALWAYS TRUE. Upstream only writes false when its injected file reader
10409
+ returns undefined or throws, and the server adapter hands back the request's own REQUIRED `content`
10410
+ field — so it can never be absent. Empty text, `{}`, and a file with no allow bucket all still report
10411
+ `found: true` (they simply yield no candidates). Do NOT use `found` as "did this layer contribute
10412
+ rules" — read `candidates` for that. The field is kept because it is part of core's preview shape
10413
+ (it only discriminates for a host whose reader touches a real filesystem).
10414
+ additionalProperties: false
10415
+ required: [path, layer, found]
10416
+ properties:
10417
+ path: { type: string }
10418
+ layer: { $ref: '#/components/schemas/CcImportSettingsLayer' }
10419
+ found: { type: boolean }
10420
+
10421
+ CcImportLayer:
10422
+ type: object
10423
+ description: >
10424
+ One settings layer submitted to rulesCcImportPrepare. The CONTENT is submitted by the caller because a
10425
+ cloud deployment has no access to the user's filesystem — that is the deployment shape, not a bypass:
10426
+ what is read is still only the allow bucket.
10427
+
10428
+ 🔴 `root` MUST be non-empty (an empty prefix contains every cwd — see RuleScope). Its TRUTHFULNESS is
10429
+ deliberately NOT validated: the server has no such tree to check against, and pretending to validate
10430
+ would be a lie. It is the user's own label for "which cwds should this rule be live under".
10431
+ # 封闭:server 侧 zod 是 `.strict()`(把 `content` 拼成 `contents` 的客户端应当场 400,而不是拿到
10432
+ # 一份「零候选」的预览去纳闷)。
10433
+ additionalProperties: false
10434
+ required: [layer, path, root, content]
10435
+ properties:
10436
+ layer: { $ref: '#/components/schemas/CcImportSettingsLayer' }
10437
+ path: { type: string, minLength: 1, maxLength: 1024 }
10438
+ root: { type: string, minLength: 1, maxLength: 1024 }
10439
+ content: { type: string, maxLength: 16384, description: 'The layer file''s raw JSON text (16 KiB cap — a settings file is hand-written, not a dataset; the cap keeps this from becoming an upload channel).' }
10440
+
10441
+ CcImportPreview:
10442
+ type: object
10443
+ description: >
10444
+ What the import WOULD do, shown before anyone confirms (core `ImportPreview`).
10445
+
10446
+ `uncovered` is a TWO-KEY RECORD, not a list: the two layers this version does not read are named in the
10447
+ TYPE, so an empty list, a missing member or a duplicate is not expressible. A preview that reported only
10448
+ the layers it read would be claiming a completeness it does not have.
10449
+ additionalProperties: false
10450
+ required: [candidates, skipped, layers, uncovered]
10451
+ properties:
10452
+ candidates:
10453
+ type: array
10454
+ items: { $ref: '#/components/schemas/RuleCandidate' }
10455
+ description: 'What would be persisted, in redemption order.'
10456
+ skipped:
10457
+ type: array
10458
+ items: { $ref: '#/components/schemas/CcImportSkippedRule' }
10459
+ description: 'Entries that did not become candidates. `reason` is PROSE, not a code — see the schema.'
10460
+ layers:
10461
+ type: array
10462
+ items: { $ref: '#/components/schemas/CcImportLayerReport' }
10463
+ uncovered:
10464
+ type: object
10465
+ additionalProperties: false
10466
+ required: [flagSettings, policySettings]
10467
+ properties:
10468
+ flagSettings: { type: string, enum: [not-imported-v1] }
10469
+ policySettings: { type: string, enum: [not-imported-v1] }
10470
+
10471
+ CcImportPrepareResult:
10472
+ type: object
10473
+ description: >
10474
+ The 200 body of rulesCcImportPrepare. NO RULE is in the store yet — the writer is only reached by
10475
+ rulesCcImportRedeem. (A pending approval record and this ticket ARE durable already; see the endpoint
10476
+ description — prepare is not idempotent.)
10477
+ additionalProperties: false
10478
+ required: [preview, ticket, expiresAtMs]
10479
+ properties:
10480
+ preview: { $ref: '#/components/schemas/CcImportPreview' }
10481
+ ticket:
10482
+ type: string
10483
+ description: >
10484
+ The redemption ticket: principal-bound, expiring, atomically single-use, and payload-bound (it pins
10485
+ a digest of the candidate set at mint time, so tampering with the record and redeeming an old ticket
10486
+ fails an equality rather than relying on "only we can write the record"). NOT a cacheable credential;
10487
+ never hand it to another user.
10488
+ expiresAtMs: { type: integer, description: 'Absolute server-clock expiry (the window is 10 minutes — one interaction, not a session).' }
10489
+
10490
+ CcImportResult:
10491
+ type: object
10492
+ description: >
10493
+ What the import ACTUALLY did (core `ImportResult`) — a different moment and a different contract from
10494
+ the preview, because dedup, concurrency and redemption-time validation can each move an entry between
10495
+ the two.
10496
+
10497
+ ⚠️ `skippedAtRedeem` is empty on every success arm of THIS lane: the server treats a non-empty one as
10498
+ INDETERMINATE, releases the claim and answers 503 `state.rule_import_retry` instead of returning a 200
10499
+ that quietly means "half of it did not land". The field stays on the shape because it is core's.
10500
+ additionalProperties: false
10501
+ required: [persisted, deduped, skippedAtRedeem, rev]
10502
+ properties:
10503
+ persisted:
10504
+ type: array
10505
+ items: { $ref: '#/components/schemas/RuleCandidate' }
10506
+ deduped:
10507
+ type: array
10508
+ items: { $ref: '#/components/schemas/RuleCandidate' }
10509
+ description: 'Already in the store — not a failure.'
10510
+ skippedAtRedeem:
10511
+ type: array
10512
+ items: { $ref: '#/components/schemas/CcImportSkippedRule' }
10513
+ rev: { type: integer, description: 'The rule store''s monotonic revision after the batch.' }
10514
+
10515
+ CcImportRedeemResult:
10516
+ type: object
10517
+ description: 'The 200 body of rulesCcImportRedeem.'
10518
+ additionalProperties: false
10519
+ required: [result]
10520
+ properties:
10521
+ result: { $ref: '#/components/schemas/CcImportResult' }
10522
+
10523
+ # ═══════════════════════════════════════════════════════════════════════════════════════════════
10524
+ # design/183 —— 身份收编(form b:纯身份重绑)。形逐字段照 server `src/adoption/wire.ts` 的 zod
10525
+ # (那份 schema 自陈是「跨仓 wire 契约,下游按字段名逐字钉围栏」)。
10526
+ # ═══════════════════════════════════════════════════════════════════════════════════════════════
10527
+
10528
+ AdoptionRequest:
10529
+ type: object
10530
+ description: 'The POST /v1/adoption body.'
10531
+ additionalProperties: false # server 侧 `.strict()`:多余键是调用方错误,当场 400(不静默吞)
10532
+ required: [fromPrincipal, toPrincipal]
10533
+ properties:
10534
+ fromPrincipal: { type: string, minLength: 1, maxLength: 190, description: 'The old identity being adopted. Already bound to a DIFFERENT destination ⇒ 409 adoption.source_already_bound (a second adoption / cross-tenant transfer is deliberately out of scope).' }
10535
+ toPrincipal: { type: string, minLength: 1, maxLength: 190, description: 'The EXISTING identity to adopt into (this form mints no credentials). Equal to fromPrincipal ⇒ 400 — that is a mistaken request, not a no-op success, and minting a row would permanently burn that source''s UNIQUE slot.' }
10536
+
10537
+ AdoptionSource:
10538
+ type: object
10539
+ description: 'Where the adoption came from. Form b has exactly one shape — a named principal.'
10540
+ additionalProperties: false
10541
+ required: [kind, principal]
10542
+ properties:
10543
+ kind: { type: string, enum: [principal] }
10544
+ principal: { type: string, minLength: 1 }
10545
+
10546
+ AdoptionLegAction:
10547
+ type: string
10548
+ enum: [bucket-rebind, row-rewrite, carried, none, reset]
10549
+ description: 'The per-store migration verb (closed set; form b only ever emits the first two plus `none`).'
10550
+
10551
+ AdoptionLeg:
10552
+ type: object
10553
+ description: 'One migration leg''s receipt.'
10554
+ additionalProperties: false
10555
+ required: [store, action]
10556
+ properties:
10557
+ store: { type: string, minLength: 1, description: 'Stable leg id = `<table>#<identity column>` (the server schema''s real names — reconcile against it).' }
10558
+ action: { $ref: '#/components/schemas/AdoptionLegAction' }
10559
+ rows:
10560
+ type: integer
10561
+ minimum: 0
10562
+ description: >
10563
+ Rows this leg changed when it RAN. 🔴 NOT a per-request delta and NOT a late-sweep delta: legs are
10564
+ only ever exposed inside `immutableReport`, so on a terminal arc every re-run replays the FROZEN
10565
+ first-run counts (a non-zero value here says nothing about what the current request did). On a
10566
+ `stalled` receipt it is the current row's reading, which can still move.
10567
+ quarantined: { type: integer, minimum: 0 }
10568
+
10569
+ AdoptionConfigEntry:
10570
+ type: object
10571
+ description: >
10572
+ One affected DEPLOYMENT configuration. 🔴 `migrated` is always false on the server half: these live in
10573
+ OTHER deployment units (BFF / cli / env / core declarations) that the adoption cannot reach — and a
10574
+ configuration one cannot change must not be reported as changed. The list being present, plus
10575
+ `migrated:false`, plus an unsettled account, is together the evidence for "configuration does not
10576
+ migrate, stated honestly".
10577
+ additionalProperties: false
10578
+ required: [deployment, key, requiredValue, migrated, ack, witnessedAtMs]
10579
+ properties:
10580
+ deployment: { type: string, minLength: 1 }
10581
+ key: { type: string, minLength: 1 }
10582
+ requiredValue: { type: string }
10583
+ migrated: { type: boolean }
10584
+ ack: { type: boolean, description: 'An operator acknowledgement — RECORD ONLY, it does not settle the account (a hand-written boolean can settle an account into a false green).' }
10585
+ witnessedAtMs:
10586
+ type: [integer, "null"]
10587
+ description: 'When a consumer''s runtime typed receipt actually read `requiredValue` back. 🔴 `null` = not witnessed = NOT settled.'
10588
+
10589
+ AdoptionNotMigratedFace:
10590
+ type: string
10591
+ enum: [usage-window, cost-quota, rate-limit, approval-exemption-grantor, approval-ask-decision-actor, image-bake-requested-by]
10592
+ description: >
10593
+ FROZEN machine-readable vocabulary of the faces the server deliberately does not migrate. Renaming or
10594
+ removing a member is a wire break; adding one is a new behaviour face. Consumers assert POSITIVELY on
10595
+ these so that "not migrated by design" and "forgotten" stay distinguishable in a black box.
10596
+
10597
+ AdoptionNotMigrated:
10598
+ type: object
10599
+ description: 'One positive "by design, not migrated" declaration.'
10600
+ additionalProperties: false
10601
+ required: [face, ruling, reason]
10602
+ properties:
10603
+ face: { $ref: '#/components/schemas/AdoptionNotMigratedFace' }
10604
+ ruling: { type: string, enum: [D1, D2], description: 'design/183 §12 ruling id — D1 = quota windows restart and recount; D2 = a historical field is never rewritten.' }
10605
+ reason: { type: string, minLength: 1 }
10606
+
10607
+ AdoptionReport:
10608
+ type: object
10609
+ description: >
10610
+ The adoption report.
10611
+
10612
+ 🔴 "IMMUTABLE" HOLDS ONLY ONCE `status` IS `adopted`. The frozen row is written once at the final phase
10613
+ and replayed byte-for-byte thereafter, never recomputed — that byte-identity IS the idempotency
10614
+ criterion. But a `stalled` receipt has no such row yet, so the server PROJECTS a same-shaped report
10615
+ from the CURRENT row (`atMs` = the row's updated-at, configs/legs = the current columns). That
10616
+ projection is provisional: the next read can differ in every field. Do NOT persist a `stalled`
10617
+ report as audit evidence — take the frozen one after `status === "adopted"`. The two share one shape
10618
+ by upstream ruling (there is only one envelope); the discriminator is `status`, not the field name.
10619
+ additionalProperties: false
10620
+ required: [adoptionId, form, from, toPrincipal, atMs, affectedDeploymentConfigs, legs, notMigratedByDesign]
10621
+ properties:
10622
+ adoptionId: { type: string, minLength: 1 }
10623
+ form: { type: string, enum: [b], description: 'This version only does form b (pure identity rebind). Present explicitly so a later form-a receipt is distinguishable without inferring from an absent key.' }
10624
+ from: { $ref: '#/components/schemas/AdoptionSource' }
10625
+ toPrincipal: { type: string, minLength: 1 }
10626
+ atMs: { type: integer, minimum: 0 }
10627
+ affectedDeploymentConfigs:
10628
+ type: array
10629
+ items: { $ref: '#/components/schemas/AdoptionConfigEntry' }
10630
+ legs:
10631
+ type: array
10632
+ items: { $ref: '#/components/schemas/AdoptionLeg' }
10633
+ notMigratedByDesign:
10634
+ type: array
10635
+ items: { $ref: '#/components/schemas/AdoptionNotMigrated' }
10636
+
10637
+ AdoptionCurrent:
10638
+ type: object
10639
+ description: 'Recomputed on every read (the counterpart to AdoptionReport''s frozen history — do not infer one from the other).'
10640
+ additionalProperties: false
10641
+ required: [outstandingConfigs, quarantined, residualSourceRows]
10642
+ properties:
10643
+ outstandingConfigs:
10644
+ type: array
10645
+ items: { type: string }
10646
+ description: 'Config entries whose `witnessedAtMs` is null, as `<deployment>:<key>`. Empty = the account is settled.'
10647
+ quarantined: { type: integer, minimum: 0, description: 'Always 0 for form b (zero carriage ⇒ no rejected rows). The field is present so the envelope does not change shape when form a arrives.' }
10648
+ residualSourceRows:
10649
+ type: integer
10650
+ minimum: 0
10651
+ description: >
10652
+ Rows STILL under the old identity, recomputed on every read. AGGREGATION: counted PER TABLE with
10653
+ the table's identity axes unioned, so a row carrying the old identity on two or three axes counts
10654
+ ONCE; the per-table counts are then summed. It is deliberately NOT a sum over legs — reconciling
10655
+ this number against per-leg `rows` will not add up.
10656
+
10657
+ 🔴 Load-bearing: adoption does NOT fence ordinary writers ("stop the engine first" is an operator
10658
+ precondition, not a machine guarantee), so a caller still injecting the old identity keeps writing
10659
+ rows after the terminal state while the idempotent short-circuit keeps answering `adopted`. So `> 0`
10660
+ means "rows may still sit under the old identity, OR the count could not be taken" — either way, GO
10661
+ LOOK. It is NOT proof that the deployment is actively writing. A POST also sweeps those late rows in
10662
+ and re-reports this; a GET does not sweep (though it does resume an in-flight arc — see the GET
10663
+ description).
10664
+
10665
+ ⚠️ `1` IS AMBIGUOUS. When the count itself fails (query error / parse failure) the server reports
10666
+ `1` fail-closed rather than `0`, because `0` is the literal assertion "everything is clean" and it
10667
+ has not earned the right to say that. So `1` may mean "one residual row" OR "could not measure";
10668
+ the wire cannot tell them apart (the server log carries `adoption_residual_count_failed`). Use it
10669
+ as intended — `> 0` ⇒ GO LOOK — but never feed `=== 1` to a report as the exact number of rows.
10670
+
10671
+ AdoptionReceipt:
10672
+ type: object
10673
+ description: >
10674
+ The ONE adoption receipt envelope — the same SHAPE for the first success and for every idempotent
10675
+ re-run. No second receipt shape exists anywhere.
10676
+
10677
+ ⚠️ Same shape carries NO byte-identity guarantee: `current` is recomputed on every call (residual
10678
+ count, config ledger) so two reads may match or may differ, and before the terminal state
10679
+ `immutableReport` is itself a provisional projection. The one thing guaranteed frozen is a TERMINAL
10680
+ `immutableReport`.
10681
+
10682
+ 🔴 `adoptionId` is at the TOP LEVEL (the server sends `{adoptionId, ...receipt}`), carrying the same
10683
+ value as `immutableReport.adoptionId`.
10684
+ 🔴 `status` is a TWO-WORD closed set. `rejected` is deliberately NOT a member: a refused adoption never
10685
+ produces a receipt, it answers a typed 409 — otherwise "refused" and "succeeded" would share one 200
10686
+ envelope and a consumer could only guess by reading fields.
10687
+ 🔴 `status` is ALSO the precondition for reading `immutableReport`: it is frozen only once `adopted`;
10688
+ on `stalled` it is a provisional projection of the current row (see AdoptionReport).
10689
+ additionalProperties: false
10690
+ required: [adoptionId, status, immutableReport, current]
10691
+ properties:
10692
+ adoptionId: { type: string, minLength: 1 }
10693
+ status: { type: string, enum: [adopted, stalled], description: '`adopted` = the terminal state is in place (and only then is `immutableReport` frozen); `stalled` = in flight (the lock is held by another replica, or a crashed run has not been resumed yet). Not a failure.' }
10694
+ immutableReport:
10695
+ allOf: [{ $ref: '#/components/schemas/AdoptionReport' }]
10696
+ description: 'Frozen ONLY when `status` is `adopted`; on `stalled` it is a provisional projection of the current row — do not persist it as audit evidence.'
10697
+ current: { $ref: '#/components/schemas/AdoptionCurrent' }