@sema-agent/sdk 6.12.0 → 6.14.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,376 @@ 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/203 §2(server 7.12.0)—— 持久权限规则的**撤销面**两口。与上面的导入两口共用部署谓词
4088
+ # (PERMISSION_RULES_ENABLED + 规则店装配,缺一 ⇒ 501),但多一条 operator 越权域:`principal` 让一个
4089
+ # 列了名的 operator 读或收回**任一租户**的规则。方向决定授权 —— 收回是 TIGHTENING(core removePersistedRule
4090
+ # 头注逐字:a host may narrow on a user's behalf, it may not widen),而回收权限正是撤销面的本义。
4091
+ # 🔴 路径**没有 :id 段**:一条规则的身份就是它的 (rule, scope) 内容对,服务端不铸行 id,所以删是按内容删。
4092
+ /v1/rules:
4093
+ parameters:
4094
+ - $ref: '#/components/parameters/PrincipalHeader'
4095
+ get:
4096
+ tags: [rules]
4097
+ operationId: rulesList
4098
+ x-status: gated # server routes/rules.ts handleRuleList;同 cc-import 的部署谓词。SDK rules.list() 消费。
4099
+ summary: List the rules that are LIVE under a principal (keyset-paged, cursor bound to the revision).
4100
+ description: >
4101
+ List the persisted allow rules currently live under one principal (tombstones already folded).
4102
+ Ordering is deterministic — `(scope, rule)` lexicographic — because keyset paging's whole premise is
4103
+ that the same data comes back in the same order every time. The order is decided by the server, NOT
4104
+ by the store (core's `list()` promises none).
4105
+
4106
+ 🔴 THE CURSOR IS BOUND TO `(rev, principal, scope)` AND A MISMATCH IS REFUSED, NOT RESET. A rule set
4107
+ is read WHOLE, so there is no tearing WITHIN a page — tearing happens BETWEEN pages, when somebody
4108
+ adds or removes a rule after page 1 and the keyset start now lands in a different set (silently
4109
+ dropped or duplicated rows). Sending a cursor minted at another revision / principal / scope filter
4110
+ gets a 400 (`request.query_invalid`, body carries the current `rev`); restart the listing without a
4111
+ cursor. Silently starting over would let a client read page 2 as "continuing page 1" and hand a
4112
+ governance surface a list that is both short and duplicated, with nobody the wiser.
4113
+
4114
+ `scope` FILTER: given but unreadable ⇒ 400, never "treated as unfiltered" — that would silently turn
4115
+ "show me only this project" into a full listing the caller would accept as the project's list.
4116
+ `limit` is CLAMPED (not refused) into 1..200, default 50 — a mistyped paging knob must not make a
4117
+ governance page unopenable.
4118
+
4119
+ OPERATOR OVERRIDE: `principal` reads ANOTHER tenant's rules and is `OPERATOR_PRINCIPALS`-gated
4120
+ (an EMPTY list means NOBODY — never inferred from the service token, which is a shared deployment
4121
+ credential, not a person). Omitted, or equal to your own principal, is the ordinary self-read and
4122
+ needs no operator standing.
4123
+ parameters:
4124
+ - name: principal
4125
+ in: query
4126
+ required: false
4127
+ schema: { type: string, minLength: 1, maxLength: 190 }
4128
+ description: >
4129
+ OPERATOR-ONLY cross-tenant read. Absent (or equal to the caller's own principal) = read your own
4130
+ rules. Anything else requires explicit OPERATOR_PRINCIPALS membership ⇒ otherwise 403
4131
+ `auth.operator_only`. Rule text contains command shapes, so third-party reads are an information
4132
+ disclosure and are deliberately not opened to non-operators.
4133
+ - name: scope
4134
+ in: query
4135
+ required: false
4136
+ schema: { type: string, minLength: 1 }
4137
+ description: >
4138
+ Filter to one scope, spelled `global` or `project:<root>` with a NON-EMPTY root. Unreadable ⇒ 400
4139
+ `request.query_invalid` (never silently unfiltered). Matching is done on the CANONICAL form, so an
4140
+ equivalent-but-differently-spelled root filters to an empty page.
4141
+ - name: limit
4142
+ in: query
4143
+ required: false
4144
+ schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
4145
+ description: 'Page size. CLAMPED into 1..200 (non-numeric / out-of-range is clamped, not refused); default 50.'
4146
+ - name: cursor
4147
+ in: query
4148
+ required: false
4149
+ schema: { type: string }
4150
+ description: >
4151
+ The `nextCursor` from the previous page, VERBATIM (base64url, opaque — do not parse or mint one).
4152
+ It is bound to the revision, the principal and the scope filter it was minted on; any mismatch is
4153
+ a 400 `request.query_invalid` carrying the current `rev`. Restart the listing without a cursor.
4154
+ responses:
4155
+ '200':
4156
+ description: One page of live rules plus the store revision the page was read at.
4157
+ content:
4158
+ application/json:
4159
+ schema: { $ref: '#/components/schemas/RuleListResult' }
4160
+ '400': { $ref: '#/components/responses/BadRequest' } # request.query_invalid — unreadable `scope`, unreadable `cursor`, or a cursor minted at a different (rev, principal, scope). Body carries `rev` on the revision-mismatch arm.
4161
+ '401': { $ref: '#/components/responses/Unauthorized' }
4162
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.operator_only — reading another principal's rules without explicit OPERATOR_PRINCIPALS membership (an EMPTY list means nobody)
4163
+ '429': { $ref: '#/components/responses/RateLimited' }
4164
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.rule_store_required — this deployment has no permission-rule store wired
4165
+ delete:
4166
+ tags: [rules]
4167
+ operationId: rulesRevoke
4168
+ x-status: gated # server routes/rules.ts handleRuleRevoke;同 cc-import 的部署谓词。SDK rules.revoke() 消费。
4169
+ summary: Revoke a persisted rule BY CONTENT — `(rule, scope)`, with no id in the path.
4170
+ description: >
4171
+ Revoke one persisted allow rule identified by its CONTENT pair `(rule, scope)`. There is no `:id`
4172
+ path segment on purpose: a rule's identity IS that pair, the server mints no row id, and the engine's
4173
+ `removePersistedRule` primitive (which this endpoint always goes through) works in observed-remove
4174
+ form over the add dots.
4175
+
4176
+ 🔴 IDEMPOTENT, AND DELIBERATELY NOT A 404. Revoking twice, or revoking something that was never
4177
+ there, is `200 {status:"no-op"}` — a "did this rule ever exist?" 404 would be an existence oracle
4178
+ (a CROSS-TENANT one under the operator override), and deletion has no business answering that
4179
+ question. Read `status` to learn whether you removed anything.
4180
+
4181
+ 🔴 `stillLive` IS LOAD-BEARING ON BOTH ARMS. `true` means the tombstone landed but the rule is
4182
+ STILL live under add-wins — a fresh approval was recorded during the call. Folding that into a plain
4183
+ 200 would let a client read an INCOMPLETE revocation as complete, which is precisely what a
4184
+ governance surface must never get wrong. The `no-op` arm carries it for the same reason: the engine
4185
+ answers `no-op` off its initial snapshot without a read-back, so an approval landing in that window
4186
+ leaves the rule alive while the verb says "nothing to remove". Both arms therefore ship the same key
4187
+ set and one consumer branch reads both.
4188
+
4189
+ OPERATOR OVERRIDE: `principal` revokes on ANOTHER tenant's behalf, gated exactly like the read half
4190
+ (explicit `OPERATOR_PRINCIPALS`; empty list = nobody). The direction is what makes the override
4191
+ legitimate — revoking is TIGHTENING — but the identity test does not relax because of it.
4192
+ requestBody:
4193
+ required: true
4194
+ content:
4195
+ application/json:
4196
+ schema: { $ref: '#/components/schemas/RuleRevokeRequest' }
4197
+ responses:
4198
+ '200':
4199
+ description: >
4200
+ The revocation outcome. `removed` = a tombstone was written; `no-op` = nothing matched (idempotent
4201
+ repeat, or it was never there). BOTH arms carry `stillLive` — read it before telling a human the
4202
+ rule is gone.
4203
+ content:
4204
+ application/json:
4205
+ schema: { $ref: '#/components/schemas/RuleRevokeResult' }
4206
+ '400': { $ref: '#/components/responses/BadRequest' } # request.body_shape — body is not {rule, scope, principal?}, or `scope` is not `global`/`project:<non-empty root>`
4207
+ '401': { $ref: '#/components/responses/Unauthorized' }
4208
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.operator_only — revoking another principal's rule without explicit OPERATOR_PRINCIPALS membership
4209
+ '429': { $ref: '#/components/responses/RateLimited' }
4210
+ '503':
4211
+ description: >
4212
+ errorCode `state.rule_remove_failed` — the engine could not complete the removal right now. Core's
4213
+ contract for this arm is "a read-back confirmed nothing was written, OR the read-back itself failed
4214
+ and the outcome is undetermined" — both are RETRY-CAN-CHANGE-IT, which is why it is a 503 and not a
4215
+ 500. Retry, then reconcile with `GET /v1/rules`.
4216
+ content:
4217
+ application/json:
4218
+ schema: { $ref: '#/components/schemas/ErrorResponse' }
4219
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.rule_store_required
4220
+
4221
+ # ── design/183 —— 身份收编面(form b:纯身份重绑)。operator-only 的一次性部署级动作,零模型工作。 ──
4222
+ /v1/adoption:
4223
+ parameters:
4224
+ - $ref: '#/components/parameters/PrincipalHeader'
4225
+ post:
4226
+ tags: [adoption]
4227
+ operationId: adoptionStart
4228
+ x-status: gated # server routes/adoption.ts;需 SQL 后端(DB_BACKEND=mysql|pg),否则 501 capability.adoption_store_required。SDK adoption.start() 消费。
4229
+ summary: Adopt one principal's data under another principal (identity rebind, form b).
4230
+ description: >
4231
+ A one-shot DEPLOYMENT action, not a business surface: operator-only, nothing billed, and honestly 501
4232
+ on a deployment with no SQL backend (the identity axis being rebound does not exist on the local one).
4233
+
4234
+ IDEMPOTENT: re-running with the same parameters never starts a second adoption, and ONCE THE ARC IS
4235
+ TERMINAL it replays the same frozen `immutableReport` byte-for-byte (written once at the final phase,
4236
+ never recomputed). The RECEIPT AS A WHOLE carries no byte-identity guarantee — `current` is recomputed
4237
+ on every call, so two reads may match or may differ — and before the terminal state
4238
+ (`status: "stalled"`) the report itself is a provisional projection of the current row. Concurrent identical POSTs are
4239
+ arbitrated by a UNIQUE on the source principal: exactly one row lands and both connections read the
4240
+ same `adoptionId`.
4241
+
4242
+ 🔴 A REFUSED ADOPTION NEVER PRODUCES A RECEIPT. `status` is a two-word closed set
4243
+ (`adopted`/`stalled`) with NO `rejected` member — refusals are typed 409s. Otherwise "refused" and
4244
+ "succeeded" would share one 200 envelope and a consumer could only guess by reading fields.
4245
+
4246
+ 🔴 This does NOT fence off ordinary writers. "Stop the engine first" is an OPERATOR PRECONDITION, not
4247
+ a machine guarantee: a caller still injecting the old identity keeps writing rows after the terminal
4248
+ state, while the idempotent short-circuit keeps answering `adopted`. That is what
4249
+ `current.residualSourceRows` makes visible — `> 0` means rows MAY still sit under the old identity OR
4250
+ the count itself failed (the server reports 1 fail-closed when it cannot count); either way, go look.
4251
+ It is not proof of an active writer. A POST (a write verb) also sweeps those late rows into the new
4252
+ identity and re-reports the count; a GET does not sweep — but it DOES resume an in-flight arc (see the
4253
+ GET description), so it is not a safe poll either.
4254
+ requestBody:
4255
+ required: true
4256
+ content:
4257
+ application/json:
4258
+ schema: { $ref: '#/components/schemas/AdoptionRequest' }
4259
+ responses:
4260
+ '200':
4261
+ description: >
4262
+ The adoption receipt — the SAME ENVELOPE SHAPE for the first success and for every idempotent
4263
+ re-run. There is NO byte-identity guarantee for the whole receipt (it may match or differ):
4264
+ `current` is recomputed on every call. The one frozen part is a TERMINAL `immutableReport`.
4265
+ content:
4266
+ application/json:
4267
+ schema: { $ref: '#/components/schemas/AdoptionReceipt' }
4268
+ '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
4269
+ '401': { $ref: '#/components/responses/Unauthorized' }
4270
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.operator_only
4271
+ '409':
4272
+ description: >
4273
+ A refusal — THREE distinct errorCodes ride this status and the operator's next action differs for
4274
+ each, so branch on `errorCode`, never on the status:
4275
+ `adoption.source_already_bound` (this source is already bound to a DIFFERENT destination — a
4276
+ second adoption / cross-tenant transfer, deliberately out of scope; body carries `adoptionId`,
4277
+ `fromPrincipal`, `boundTo`);
4278
+ `adoption.destination_conflict` (the destination already holds rows sharing a logical key with
4279
+ the source; body lists the conflicting tables in `conflicts` — go clean those up);
4280
+ `adoption.destination_unrepresentable` (the destination identity does not fit some key this
4281
+ adoption would DERIVE from it, e.g. a memory `proj:` key overrunning a column width — pick a
4282
+ SHORTER destination principal).
4283
+ Either way both sides are byte-unchanged.
4284
+ content:
4285
+ application/json:
4286
+ schema: { $ref: '#/components/schemas/ErrorResponse' }
4287
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.adoption_store_required — adoption requires a SQL store backend (DB_BACKEND=mysql|pg)
4288
+
4289
+ /v1/adoption/{adoptionId}:
4290
+ parameters:
4291
+ - $ref: '#/components/parameters/PrincipalHeader'
4292
+ - { name: adoptionId, in: path, required: true, schema: { type: string }, description: 'From AdoptionReceipt.adoptionId.' }
4293
+ get:
4294
+ tags: [adoption]
4295
+ operationId: adoptionGet
4296
+ x-status: gated # server routes/adoption.ts;同 POST 的部署谓词。SDK adoption.get() 消费。
4297
+ summary: Read one adoption's state (and RESUME it if it is still in flight).
4298
+ description: >
4299
+ 🔴 THIS IS NOT A SIDE-EFFECT-FREE READ. On a TERMINAL row (adopted / refused) it is a pure read. On a
4300
+ row that is STILL IN FLIGHT — the typical case being a deployment where nothing has POSTed again since
4301
+ a crash — it RESUMES THE ARC: the same `driveUnderLock` the boot scan uses, advancing phases, rewriting
4302
+ rows on the identity axis, recording the config ledger and landing the terminal state. That is
4303
+ deliberate upstream (otherwise one crash would require a human to re-POST), but a consumer must know
4304
+ it: do NOT treat this as a safe prefetch / dashboard poll — reading an in-flight arc is pressing
4305
+ continue on it. The ONLY difference from the POST is that the GET does NOT sweep late rows into the
4306
+ new identity (the POST does). Repeating the call is safe (advisory lock + phase CAS + idempotent
4307
+ identity ⇒ a replay lands where one call lands) — but "safely replayable" is not "read-only". There
4308
+ is no verb on this wire today that guarantees zero progression.
4309
+
4310
+ `immutableReport` is historical fact — but only ONCE `status` is `adopted`; on a `stalled` receipt it is
4311
+ a provisional projection of the current row and can differ between two reads (see the AdoptionReport
4312
+ schema). `current` is recomputed every time. Do not infer one half from the other — in particular
4313
+ `status: "adopted"` does NOT mean the configuration migration is done; that account lives in
4314
+ `current.outstandingConfigs` / `current.residualSourceRows`, and a non-zero residual may also mean the
4315
+ count itself failed (see that field).
4316
+ responses:
4317
+ '200':
4318
+ description: The adoption receipt.
4319
+ content:
4320
+ application/json:
4321
+ schema: { $ref: '#/components/schemas/AdoptionReceipt' }
4322
+ '400': { $ref: '#/components/responses/BadRequest' } # request.id_invalid — undecodable id (shared with sessions/attachments; no second synonym code was minted)
4323
+ '401': { $ref: '#/components/responses/Unauthorized' }
4324
+ '403': { $ref: '#/components/responses/Forbidden' } # auth.operator_only
4325
+ '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.
4326
+ '409':
4327
+ description: 'Same three refusal codes as adoptionStart (a refused adoption keeps answering its refusal).'
4328
+ content:
4329
+ application/json:
4330
+ schema: { $ref: '#/components/schemas/ErrorResponse' }
4331
+ '501': { $ref: '#/components/responses/NotImplemented' } # capability.adoption_store_required
4332
+
3858
4333
  /metrics/summary:
3859
4334
  get:
3860
4335
  tags: [metrics]
@@ -4479,6 +4954,35 @@ components:
4479
4954
  items: { $ref: '#/components/schemas/TaskAgentDefinition' }
4480
4955
  description: Per-task sub-agent definitions (roster additions scoped to this task only).
4481
4956
  interactiveTools: { type: boolean, description: Mount interactive (prompting) tools. }
4957
+ oneShot:
4958
+ type: boolean
4959
+ description: >
4960
+ core >=5.23.0 `TaskSpec.oneShot`, server >=7.12.0 — declare that THIS SUBMISSION is one-shot: no
4961
+ later turn exists in which an asynchronous background notification could land. The archetype is a
4962
+ headless `sema -p` whose process exits when the turn ends.
4963
+
4964
+ 🔴 IT GRANTS NOTHING AND GUARANTEES NOTHING — core consumes it as GUIDANCE ONLY. What changes is
4965
+ the wording the engine gives the MODEL for `RunWorkflow` / delegation receipts: "end your turn, you
4966
+ will be notified" (correct only when a next turn exists) becomes "block-wait via
4967
+ `TaskOutput({block:true})`". Omitting it on a headless call is a known way to LOSE background
4968
+ results — that is why the key exists — but sending it is **best-effort**, not a completion
4969
+ contract: the model may ignore the instruction or run out of budget, and some legs have no poll
4970
+ path at all. If your caller REQUIRES the results, keep your own wait/reconcile step; do not treat
4971
+ `oneShot: true` as one. There is no tenancy gate because there is no capability to gate.
4972
+
4973
+ 🔴 PROBE `capabilities.oneShot` BEFORE SENDING IT: the task body is an OPEN set (the server does
4974
+ not refuse unknown keys), so a worker that does not yet consume this field accepts `oneShot: true`,
4975
+ answers 200, and still tells the model to end its turn and await a notification the process will
4976
+ never receive — no 400, no warning. The capability bit (server >=7.12.0) is what distinguishes the
4977
+ two; ABSENT means the key is being ignored. Note the bit being PRESENT does not retire the
4978
+ caller-side wait step: it proves the key is consumed, not that the results arrive.
4979
+
4980
+ PER-REQUEST on purpose: "does this submission expect to be continued" is a property of the
4981
+ SUBMISSION, not of the connection it arrived on. Sibling of `interactiveTools` (the same `-p`
4982
+ posture) and passed through identically — a boolean rides, ABSENT ⇒ the key is omitted and core's
4983
+ default (interactive) applies. Anything that is neither absent nor a boolean is a fail-loud 400
4984
+ `request.field_invalid` ("oneShot must be a boolean"), NOT a silent coercion. Rides the persisted
4985
+ body onto resume legs.
4482
4986
  retainBackgroundProcesses: { type: boolean, description: Keep background processes alive past the turn. }
4483
4987
  forwardSubagentEvents:
4484
4988
  type: boolean
@@ -4927,8 +5431,12 @@ components:
4927
5431
  type: object
4928
5432
  description: >
4929
5433
  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).
5434
+ the user/objective opening turn is NOT projected today). blocks is an OPEN set. tool-result.output IS
5435
+ present (server trace/project.ts projects it; the old "ALWAYS absent" note was stale — [3315] finding) —
5436
+ but note it carries the STORED form: outputs over the engine's offload threshold are persisted in
5437
+ bounded form (head/tail preview + `<persisted-output ref>` marker), so `output` here can be the
5438
+ truncated form, disclosed via `truncated`/`totalChars`. Full retrieval of an offloaded output awaits
5439
+ the tool-results read face ([3316] server half, planned).
4932
5440
  required: [seq, ts, role, blocks]
4933
5441
  additionalProperties: true
4934
5442
  properties:
@@ -4949,6 +5457,20 @@ components:
4949
5457
  input: { description: tool-call — redacted args. }
4950
5458
  callId: { type: string }
4951
5459
  isError: { type: boolean }
5460
+ # 🔴 core >=5.18.1 (#187) —— 与 `Event_tool_end.settledBy` **同一个值**:server 的共享挑键器
5461
+ # `toolResultFieldsOf`(src/trace/project.ts)同时喂 turns 面与 trace SSE 面,而写侧
5462
+ # `toolEndEventData` 喂 durable 账本 —— 三面一个真源,所以这里的词表与那边逐字相同。
5463
+ settledBy:
5464
+ type: string
5465
+ enum: [human, timeout, aborted]
5466
+ description: >
5467
+ tool-result — HOW this call was settled when it went through an approval: `human` = somebody
5468
+ actually answered; `timeout` = the approval window elapsed; `aborted` = abort / unclonable
5469
+ arguments / out-of-contract. Present ONLY on the settled call.
5470
+ ABSENCE CARRIES NO SEMANTICS (an older worker, or a posture arm that deliberately leaves it
5471
+ unset — the two are indistinguishable), so absent is neither "human" nor "no approval
5472
+ happened". The read leg re-validates the closed set independently of the write leg, because a
5473
+ ledger row can come from any engine generation.
4952
5474
  tokens:
4953
5475
  type: object
4954
5476
  properties:
@@ -4968,6 +5490,9 @@ components:
4968
5490
  # 于是「server 少发 retainedFrom」这种断页判据丢失对本门不可见。
4969
5491
  additionalProperties: false
4970
5492
  required: [turns, retainedFrom]
5493
+ # ⚠️ 路别差异契约([3315]② 定谳,core [3316] 判设计事实非缺陷):trace 记账只在 /v1/runs 路;
5494
+ # sync `POST /v1/tasks` 完成后的 turns 读面对该 run 恒 `turns: []`(retainedFrom: 0)。这是契约,
5495
+ # 不是待修缺口——sync 路要 trace 请改走 /v1/runs。哪天 sync 路开始填 turns = 行为变更需过本表。
4971
5496
  properties:
4972
5497
  turns:
4973
5498
  type: array
@@ -5048,6 +5573,21 @@ components:
5048
5573
  type: array
5049
5574
  items: { $ref: '#/components/schemas/Artifact' }
5050
5575
 
5576
+ ToolResultSlice:
5577
+ type: object
5578
+ description: >
5579
+ One slice of an offloaded tool result (`GET /v1/tasks/{taskId}/tool-results/{ref}`) — the wire
5580
+ form of the engine's `ToolResultSlice`. `offset` echoes where this slice starts (so successive
5581
+ reads concatenate deterministically) and `totalChars` is the length of the FULL stored content,
5582
+ independent of the slice — a client pages until `offset + content.length === totalChars`.
5583
+ # 封闭:铸造点 routes/trace-usage.ts `handleToolResult` 只发这三键(core ToolResultSlice 原形)。
5584
+ additionalProperties: false
5585
+ required: [content, offset, totalChars]
5586
+ properties:
5587
+ content: { type: string, description: 'The slice text.' }
5588
+ offset: { type: integer, description: 'Char offset this slice starts at.' }
5589
+ totalChars: { type: integer, description: 'Total chars of the FULL stored content (not this slice).' }
5590
+
5051
5591
  LeaderReceipt:
5052
5592
  type: object
5053
5593
  description: >
@@ -5186,6 +5726,76 @@ components:
5186
5726
  legacy verbs in 1.0.0; the live face is `client.memory.exportScope` / `.sync`
5187
5727
  (`GET /v1/memory/export`, `POST /v1/memory/sync/{scope}`), which this flag does NOT gate.
5188
5728
  Kept on the wire so an older shell that still reads it honestly hides the dead affordances.
5729
+ adoption:
5730
+ type: boolean
5731
+ description: >
5732
+ design/183 form b adoption face (POST /v1/adoption + GET /v1/adoption/:id, operator lane) is
5733
+ wired. Same predicate as the routes' 501 (the SQL backend's adoptionLog factory is present;
5734
+ the local backend deliberately omits it). Probe before use, no trial-by-501.
5735
+ permissionRules:
5736
+ type: boolean
5737
+ description: >
5738
+ The permission-rule lane is available: /v1/rules/cc-import/* , the respond persistRule redemption
5739
+ arm, and (from server 7.12.0) the GET/DELETE /v1/rules revocation face. Server predicate =
5740
+ PERMISSION_RULES_ENABLED AND a wired rule store — the SAME predicate behind every one of those
5741
+ endpoints' 501s, so "says yes" means all of the ones this server has actually work.
5742
+ 🔴 THE KNOB'S DEFAULT READS IN TWO SEGMENTS: default OFF (opt-in) on server <= 7.11.0, default ON
5743
+ (opt-out) from 7.12.0, where the revocation face — the prerequisite that kept it opt-in — landed.
5744
+ So "the operator said nothing" means OPPOSITE things across that boundary; only an explicit
5745
+ `false` disables it. Read THIS BIT; never infer the lane's state from a knob default or a
5746
+ version number.
5747
+ permissionRulesRevoke:
5748
+ type: boolean
5749
+ description: >
5750
+ server >=7.12.0 — the rule REVOCATION face (GET + DELETE /v1/rules) is ROUTED on this worker.
5751
+ Same deps predicate as `permissionRules`; what carries the extra information is this key's mere
5752
+ PRESENCE. 🔴 That is the whole point of it being a second bit rather than a tightening of the
5753
+ first: on a <=7.11.0 worker `permissionRules` already answers true while these two routes 404, so
5754
+ that bit cannot distinguish "the lane is off" from "this build predates the face", and its
5755
+ published wording ("all four endpoints work") is already untrue there — you can add a bit, you
5756
+ cannot retroactively change one. ABSENT => an older worker: expect 404 `not_found.route` from
5757
+ `rules.list` / `rules.revoke` and hide the governance affordance. PRESENT => the routes exist
5758
+ (whether they then answer depends on `permissionRules`, as always).
5759
+ modeShellGateTranslation:
5760
+ type: boolean
5761
+ description: >
5762
+ design/201 §3 (server >=7.12.0) — this binary CONTAINS the `permissionMode` -> `spec.shellGate`
5763
+ translation table (bypassPermissions => off; auto/default/acceptEdits/plan => classify; no stated
5764
+ mode => the key is not written). HARD-CODED true and landed in the SAME COMMIT as the table, so
5765
+ "says yes <=> the translation is really there" is structural, not deps-derived — no missing
5766
+ dependency can make it false.
5767
+ 🔴 THE PROBE MUST BE THIS BIT ON THE SERVER YOU ARE TALKING TO, NEVER YOUR OWN SHELL VERSION. A
5768
+ shell drops its unconditional `MANUAL_MODE_SHELL_GATE` env injection on the strength of this bit;
5769
+ keyed off the shell's own version instead, the three real deployments — new shell against an old
5770
+ server, a replica rolled back to an older binary, a mixed-version fleet — each open a window where
5771
+ the shell has stopped injecting and the server cannot yet translate. Absent/false ⇒ keep injecting.
5772
+ oneShot:
5773
+ type: boolean
5774
+ description: >
5775
+ server >=7.12.0 — this worker actually CONSUMES `TaskRequest.oneShot` (projects it into `TaskSpec`
5776
+ when it is a boolean, omits the key when absent, and 400s a non-boolean). Hard-coded true: the
5777
+ consumption depends on no optional facility. 🔴 PROBE IT BEFORE RELYING ON THE FIELD: the task
5778
+ request body is an OPEN set, so a worker that does not consume the key still accepts it and
5779
+ answers 200 — a headless caller would declare itself one-shot, never receive core's block-wait
5780
+ guidance, and lose its background results with nothing on the wire to reveal it. ABSENT => the key
5781
+ is being ignored; keep your own wait/reconcile step. (That step remains good practice even when
5782
+ the bit is present: the field is guidance to the model, not a completion contract.)
5783
+ outcomeLedger:
5784
+ type: boolean
5785
+ description: >
5786
+ server >=7.12.0 — the `GET /v1/outcomes` mechanical-signal read aggregate (design/73 §7.2) is
5787
+ available. Predicate mirrors the route's refusal arms exactly: a QUERYABLE ledger is wired (a
5788
+ file-only sink writes JSONL and has no query face ⇒ 501), AND on a multi-tenant deployment the
5789
+ operator list is non-empty (single-user has no such gate — the only user IS the operator).
5790
+ Added because this face previously had a 501 but NO capability bit, leaving consumers to
5791
+ trial-by-501 — the exact pattern this capability surface exists to remove.
5792
+ sharedMemory:
5793
+ type: boolean
5794
+ description: >
5795
+ design/177 org shared-memory read face (`GET /v1/shared-memory/*`) is mounted. Server-side the
5796
+ predicate is the SAME conjunction that mounts the route domain (SQL store present AND the
5797
+ principal→org membership fold present), so true ⟺ the face actually works — a deployment with
5798
+ the store but no membership authority deliberately reports false (and does not mount the routes).
5189
5799
  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
5800
  workflowsList: { type: boolean, description: "`GET /v1/workflows` list face (separate from the per-run reads)." }
5191
5801
  resumeAt: { type: boolean, description: "E18 resume-at-message anchors are resolvable." }
@@ -7752,8 +8362,35 @@ components:
7752
8362
  redeem it. The engine ALWAYS emits the section, so `false` is a REAL reading ("no rule lane on this
7753
8363
  worker"), NOT "this engine is too old". The section is optional HERE only because this spec's support
7754
8364
  floor is server 3.0.0, which predates it; absent = the worker never sent it.
8365
+
8366
+ 🔴 `syncWired` / `orgGoverned` (core >=5.23.0 design/182 §9/§7, server >=7.12.0) sit in THIS
8367
+ section — the engine deliberately did not put them in the operator-only `governance` section, and
8368
+ the server projects all three bits on both faces. THEY WERE THE REASON THIS SECTION HAD TO BE
8369
+ REDECLARED: this arm is `additionalProperties: false`, so a strict consumer validating a real
8370
+ frame against the 6.13.0 spec did not "miss two fields" — the WHOLE SECTION failed validation and
8371
+ the diagnostics surface went unreadable. Same failure family as `errorCode` on `Event_tool_end`.
7755
8372
  properties:
7756
8373
  storeWired: { type: boolean }
8374
+ syncWired:
8375
+ type: boolean
8376
+ description: >
8377
+ core >=5.23.0 (design/182 §9) — this worker merges persisted rules into the SAME consent trust
8378
+ domain as its cross-device / transport / server-side peers. Read with `storeWired` to answer
8379
+ "how far does this 'don't ask again' actually travel?": the same click means something wider
8380
+ here than on a worker that keeps its rules local.
8381
+ 🔴 PRESENT-AND-FALSE IS A REAL READING — "this engine has the concept, this deployment has not
8382
+ wired it" — NOT "too old to know". Absent (the whole key missing) is the only "did not send it"
8383
+ signal. On server 7.12.0 this is honestly `false` everywhere: the seam exists in core, the
8384
+ server has not wired a supplier yet.
8385
+ orgGoverned:
8386
+ type: boolean
8387
+ description: >
8388
+ core >=5.23.0 (design/182 §7) — an ORGANIZATION governance layer is armed over this rule lane.
8389
+ Load-bearing consequence: that layer is FAIL-CLOSED, so when the org snapshot cannot be read,
8390
+ EVERY allow is escalated into a real human approval rather than quietly honored. A shell that
8391
+ renders "don't ask again" without reading this bit is advertising an outcome it cannot promise.
8392
+ 🔴 Same three-way reading as `syncWired`: present-and-false = concept known, not armed here;
8393
+ absent = the worker never sent the key. Honestly `false` on server 7.12.0.
7757
8394
  governance:
7758
8395
  type: object
7759
8396
  additionalProperties: false
@@ -8150,6 +8787,44 @@ components:
8150
8787
  governance shell gate sits at the "classify" tier, a shell ask may come from the classifier or from
8151
8788
  another gate; the server leaves the key absent rather than guessing). Never render absence as
8152
8789
  "this gate can be bypassed by a posture".
8790
+ ruleSuggestions:
8791
+ type: array
8792
+ minItems: 1 # 空数组不可能出现:server 的合取项②要求 `req.ruleSuggestions` **非空**,否则整键省略(缺席 = 本卡无此选项)
8793
+ # 刻意**不**声明 maxItems:server 的 `.max(4)` 是**卡** schema(`ApprovalCardSchema`)上的约束,这条
8794
+ # live 帧腿没有它。在这里抄一个上界 = 声明一条 server 并不执行的约束(照 spec 生成的严格客户端会
8795
+ # 拒收一条合法帧)。数量上界的真源是引擎:core 5.22.0 的 `suggestRulesForCommand` **至多铸一条,且恒
8796
+ # `match: "exact"`**(不写死上界是给将来的 miner 留位,不是暗示今天会有更多)。
8797
+ items: { $ref: '#/components/schemas/RuleSuggestion' }
8798
+ description: >
8799
+ server #154 车二 (core 5.18.0, design/179), ADDITIVE, "tool_approval" only: the rule candidates
8800
+ the ENGINE minted for this ask. Render them as the "don't ask again" options and echo the chosen
8801
+ one back as `persistRule.rule` on POST /v1/tool-approvals/{id}/respond.
8802
+
8803
+ 🔴 PRESENCE IS THE PROMISE: the key is present ONLY on a deployment that actually has a
8804
+ permission-rule store wired (the same object behind the engine's `permissionRules.storeWired`
8805
+ manifest bit). A deployment without one OMITS it rather than offering an option with nowhere to
8806
+ redeem — an unredeemable "don't ask again" is a wire lie, worse than absence.
8807
+
8808
+ 🔴 THE CONVERSE DOES NOT HOLD: absence means "THIS CARD has no redeemable candidate", and three
8809
+ causes produce it — no rule store wired, the engine minted no candidate for this command (compound /
8810
+ redirect / substitution), or the command bytes were unreadable. One frame cannot tell them apart, so
8811
+ never diagnose the deployment's rule lane from a single absence, and never synthesize a candidate.
8812
+
8813
+ 📏 HOW MANY, TODAY: core 5.22.0's miner (`suggestRulesForCommand`) emits AT MOST ONE candidate and
8814
+ always `match: "exact"` — no shipped producer mints the broader prefix option. `prefix` stays in the
8815
+ closed vocabulary because the wire validator accepts it and a later miner may emit it; render what
8816
+ arrives, but do NOT build a UI that assumes a narrow/broad PAIR is on offer.
8817
+
8818
+ 🔴 The values are the engine's VERBATIM (closed `match` vocabulary + engine-minted text); the
8819
+ server neither re-mints nor re-orders them. A hand-built rule string is refused at redemption
8820
+ (`ruleRefusal: "rule_not_offered"`) — by construction, so a click on a harmless command cannot be
8821
+ turned into a rule for something else.
8822
+
8823
+ ⚠️ REDEMPTION SCOPE: the key also rides the durable card replay, but its only redemption channel is
8824
+ the LIVE respond leg keyed by this frame's `approvalId` — an in-process map on ONE replica. Same
8825
+ replica ⇒ redeemable; after a restart / on another replica that `approvalId` 404s and the person
8826
+ must use the durable decide leg, which carries NO rule field in v1 (its body is strict — an extra
8827
+ `persistRule` is a loud 400, not a silent drop).
8153
8828
  fromSubagent:
8154
8829
  type: boolean
8155
8830
  enum: [true]
@@ -8269,11 +8944,91 @@ components:
8269
8944
  posture. Semantics, the ABSENT != false clause and the discrimination boundary are stated verbatim on
8270
8945
  ToolApprovalFrame.governanceForced. It sits at the card's top level rather than inside `risk` because
8271
8946
  `risk` describes the ENGINE's judgement of the call, while this key says WHO imposed the gate.
8947
+ ruleSuggestions:
8948
+ type: array
8949
+ minItems: 1 # 同帧腿:空数组不可能出现(素材同源),缺席才是「本卡无此选项」
8950
+ maxItems: 4 # 卡形上**真有**这条上界(server `ApprovalCardSchema` 的 `.max(4)`);live 帧腿没有,故只写在这里
8951
+ items: { $ref: '#/components/schemas/RuleSuggestion' }
8952
+ description: >
8953
+ ADDITIVE (#154 车二 / core 5.18.0 design/179): the rule candidates the ENGINE minted for this ask —
8954
+ the card's "don't ask again" options; echo the chosen one back as `persistRule.rule`. Semantics,
8955
+ the PRESENCE-IS-THE-PROMISE clause (present only where a rule store is wired) and the redemption
8956
+ scope are stated verbatim on ToolApprovalFrame.ruleSuggestions — the live frame, the stored
8957
+ `card_json` and the replayed frame carry the SAME material.
8272
8958
  fromSubagent: { type: boolean, enum: [true], description: 'Present ⇔ the ask comes from a DELEGATED child — the explicit discriminator (a worker cannot forge it).' }
8273
8959
  sourceTaskId: { type: string, description: 'The child''s core session id — row-join value + fallback discriminator.' }
8274
8960
  sourceAgentName: { type: string, description: 'The child agent''s display name, redacted. UNTRUSTED.' }
8275
8961
  delegation: { $ref: '#/components/schemas/ApprovalCardDelegation' }
8276
8962
 
8963
+ RuleSuggestion:
8964
+ type: object
8965
+ description: >
8966
+ One ENGINE-minted rule candidate (#154 车二 / core 5.18.0 `RuleSuggestion`) — what a single click on
8967
+ "don't ask again" would persist.
8968
+
8969
+ 🔴 THE TEXT COMES FROM THE ENGINE, NOT THE SHELL: the server derives it from the adjudicated command's
8970
+ own bytes; a client may only echo one of these back verbatim (`persistRule.rule`). Reporting text that
8971
+ is not among the candidates is refused (`ruleRefusal: "rule_not_offered"`) rather than guessed at, so
8972
+ "click on a harmless command, get a rule for something else" is not spellable on this path.
8973
+ # 封闭:core `RuleSuggestion` 恰三键(rule/match/command),server 逐字透传不重铸。
8974
+ additionalProperties: false
8975
+ required: [rule, match, command]
8976
+ properties:
8977
+ rule: { type: string, maxLength: 512, description: 'The canonical rule text to echo back on redemption (server MAX_RULE_TEXT_CHARS = 512).' }
8978
+ match:
8979
+ type: string
8980
+ enum: [exact, prefix]
8981
+ 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.'
8982
+ command: { type: string, maxLength: 512, description: 'The command pattern inside the rule, for a shell that would rather not re-parse `rule`.' }
8983
+
8984
+ RuleRefusalReason:
8985
+ type: string
8986
+ description: >
8987
+ Why a "don't ask again" did NOT get persisted (#154 车二; the server's `RuleRefusalReason`, verbatim).
8988
+ A CLOSED set so a shell can branch instead of matching prose. The two naming styles are NOT a typo:
8989
+ the four hyphenated members are derived from the consent lane's own refusal reasons, the four
8990
+ underscored ones are minted by the server's gates.
8991
+
8992
+ 🔴 A refusal NEVER flips the decision. The person's "allow this once" has already taken effect and
8993
+ reached the engine; turning the whole respond into a 4xx would make a real allow vanish. The honest
8994
+ shape is always 200 + `rulePersisted: false` + this reason.
8995
+
8996
+ `no-candidates` — the engine could mint no candidate for this command (compound/redirect/substitution).
8997
+ `unknown-candidate` — the echoed text does not locate in the record's candidate table.
8998
+ `confirm-refused` / `redeem-refused` — the consent protocol's confirm/redeem step was refused
8999
+ (record state, a lost CAS, or the store refusing the write).
9000
+ `rule_lane_unavailable` — FOUR causes share this one value (the server's three conjuncts): no rule store
9001
+ wired, OR the engine minted no candidate for this command, OR the command bytes were unreadable, OR this
9002
+ ask has no verified owner. 🔴 Two of those are per-CARD limits, so this reason is NOT evidence about the
9003
+ deployment — never disable the rule feature globally on the strength of it.
9004
+ `rule_input_edited` — the respond ALSO carried `updatedInput` (ctrl+g edit): the call that takes effect
9005
+ is the edited one while the candidates were minted from the ORIGINAL command, so persisting would arm a
9006
+ standing allow for the original (a real privilege widening). v1 refuses the persistence; the decision
9007
+ itself still stands.
9008
+ `rule_not_offered` — the echoed text is not among THIS ask's candidates (a stale card, or someone
9009
+ trying to mint a rule of their own).
9010
+ `rule_store_error` — the rule store wobbled. The adjudication is unaffected.
9011
+ `rule_governance_forced` (server >=7.12.0, #204) — the gate on THIS ask came from the deployment's
9012
+ operator governance layer, so the ask never entered the rule lane at all.
9013
+ 🔴 SPLIT FROM `rule_lane_unavailable` ON PURPOSE: that one is "one of four conjuncts failed" (no rule
9014
+ store, no candidate, unreadable command bytes, no verified owner — two of which are per-CARD limits),
9015
+ this one is "the lane is perfectly healthy, only THIS ask is operator-governed". Folding them would
9016
+ erase a real distinction on the card.
9017
+ ⚠️ NEITHER IS A DEPLOYMENT-LEVEL VERDICT. Every member of this enum describes ONE ask's outcome; the
9018
+ note on `rule_lane_unavailable` above already says never to disable the feature globally on it, and
9019
+ the same holds here. The one correct source for "does this worker serve rules at all" is
9020
+ `capabilities.permissionRules`. Keep offering "don't ask again" on the next ask; just not on this one.
9021
+ enum:
9022
+ - no-candidates
9023
+ - unknown-candidate
9024
+ - confirm-refused
9025
+ - redeem-refused
9026
+ - rule_lane_unavailable
9027
+ - rule_input_edited
9028
+ - rule_not_offered
9029
+ - rule_store_error
9030
+ - rule_governance_forced
9031
+
8277
9032
  ApprovalRiskAxes:
8278
9033
  type: object
8279
9034
  description: >
@@ -9587,7 +10342,10 @@ components:
9587
10342
  shape, `decision` echoed). Owner-gated with a 404 and NO existence oracle: non-owner and
9588
10343
  unknown/settled/expired/wrong-replica ids are indistinguishable.
9589
10344
  # 封闭(census 批2 第三段,2026-07-30):铸造点 `tool-approval.ts` 三恒在键 + 两条件键
9590
- # (rememberApplied/updatedInputForwarded 的条件展开就在同一行字面量里),键集恰是这 5 个。
10345
+ # (rememberApplied/updatedInputForwarded 的条件展开就在同一行字面量里)。
10346
+ # #154 车二(2026-08-10):`persistRuleAfterDecision` 的 `withFlag()` 在同一个体上再盖两个条件键
10347
+ # (rulePersisted 恒在、ruleRefusal 仅失败时)—— 键集从 5 升 7,**封闭性不动**(additive 不许把
10348
+ # census 闭合的口悄悄捅开)。
9591
10349
  required: [approvalId, delivery, decision]
9592
10350
  additionalProperties: false
9593
10351
  properties:
@@ -9606,6 +10364,24 @@ components:
9606
10364
  server 1.241 ([1458]): present (true) when an allow-family decision carried `updatedInput` and
9607
10365
  the server forwarded the edited args to the engine — forwarded, not necessarily applied
9608
10366
  (consumption depends on the core OnAsk object arm). Omitted on deny / no edit / older servers.
10367
+ rulePersisted:
10368
+ type: boolean
10369
+ description: >
10370
+ server #154 车二, ADDITIVE: whether the `persistRule` this respond carried actually landed in the
10371
+ rule store.
10372
+
10373
+ 🔴 A FAILED PERSISTENCE NEVER FLIPS THE DECISION — the person's "allow this once" already took
10374
+ effect and reached the engine, so the honest shape is 200 + `rulePersisted: false` + `ruleRefusal`,
10375
+ NOT a 4xx. Render it as "allowed, but 'don't ask again' was not saved" — never as a failed approval.
10376
+ Omitted when the respond carried no `persistRule`, and on older servers; ABSENT is not `false`.
10377
+ ruleRefusal:
10378
+ allOf: [{ $ref: '#/components/schemas/RuleRefusalReason' }]
10379
+ description: >
10380
+ server #154 车二, ADDITIVE: the MACHINE-READABLE reason when `rulePersisted` is false (closed
10381
+ vocabulary). Always absent when `rulePersisted` is true. Branch on it to separate "try again on the
10382
+ next ask" (`rule_input_edited`) from "there was nothing redeemable on this card"
10383
+ (`rule_lane_unavailable`). 🔴 `rule_lane_unavailable` covers four causes, two of them per-card — do
10384
+ NOT read it as "this deployment has no rule lane" (see the RuleRefusalReason schema).
9609
10385
 
9610
10386
  UsageMetric:
9611
10387
  type: string
@@ -9830,3 +10606,490 @@ components:
9830
10606
  path: { type: string, description: 'The relPath — pass VERBATIM as ?path= on the file sub-route.' }
9831
10607
  hash: { type: string, description: 'Content address (sha256) — enables cross-snapshot change highlighting ([1894]②).' }
9832
10608
  size: { type: integer, description: 'Present only when the store has a sizes face (SQL backends).' }
10609
+
10610
+ # ═══════════════════════════════════════════════════════════════════════════════════════════════
10611
+ # #154 车二(design/179 §7)—— 持久权限规则的 CC settings 导入面。形按 server 真码 + core 原形亲读
10612
+ # (`src/http/routes/rules.ts` / `src/rules-consent.ts`;preview/result 两形是 core `ImportPreview` /
10613
+ # `ImportResult` 的原样透传)。
10614
+ # ═══════════════════════════════════════════════════════════════════════════════════════════════
10615
+
10616
+ CcImportSettingsLayer:
10617
+ type: string
10618
+ enum: [userSettings, projectSettings, localSettings]
10619
+ description: >
10620
+ The three user-editable CC settings layers this version reads (core `ImportedSettingsLayer`). The two
10621
+ layers it does NOT read are named in `CcImportPreview.uncovered` rather than being silently absent.
10622
+
10623
+ RuleScope:
10624
+ description: >
10625
+ Where a persisted rule applies (core `RuleScope`). `project.root` is the directory prefix that decides
10626
+ which cwds the rule is live under.
10627
+ oneOf:
10628
+ - type: object
10629
+ additionalProperties: false
10630
+ required: [kind]
10631
+ properties:
10632
+ kind: { type: string, enum: [global] }
10633
+ - type: object
10634
+ additionalProperties: false
10635
+ required: [kind, root]
10636
+ properties:
10637
+ kind: { type: string, enum: [project] }
10638
+ 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.' }
10639
+
10640
+ RuleCandidate:
10641
+ type: object
10642
+ description: 'One candidate rule: the canonical text plus the scope it would land in (core `RuleCandidate`).'
10643
+ additionalProperties: false
10644
+ required: [rule, scope]
10645
+ properties:
10646
+ rule: { type: string, maxLength: 512 }
10647
+ scope: { $ref: '#/components/schemas/RuleScope' }
10648
+
10649
+ CcImportSkippedRule:
10650
+ type: object
10651
+ description: >
10652
+ One entry that did NOT become a candidate.
10653
+
10654
+ 🔴 `reason` IS HUMAN-READABLE PROSE, NOT A MACHINE CODE. Upstream produces exactly three shapes:
10655
+ a grammar/validation refusal is `"<RuleRejectCode>: <message>"` (code-PREFIXED prose, e.g.
10656
+ `"invalid.bare_interpreter_prefix: …"` — never the bare code); a layer whose JSON does not parse is
10657
+ `"settings file is not valid JSON (<error text>)"`; a non-string allow entry is
10658
+ `"settings entry is not a string"`. If you must classify, key on the prefix BEFORE the first `:` and
10659
+ handle the two unprefixed shapes — do not equality-match the whole string and do not expect a bare code.
10660
+
10661
+ ⚠️ `rule` is not always a rule text either: on the unparseable-JSON arm it carries THAT LAYER'S PATH
10662
+ (no entry was parsed, so there is no rule to name). Do not assume a `Bash(...)` shape when rendering.
10663
+ additionalProperties: false
10664
+ required: [rule, reason]
10665
+ properties:
10666
+ rule: { type: string }
10667
+ reason: { type: string }
10668
+
10669
+ CcImportLayerReport:
10670
+ type: object
10671
+ description: >
10672
+ Per-layer read report inside a preview.
10673
+
10674
+ ⚠️ ON THIS HTTP ROUTE `found` IS ALWAYS TRUE. Upstream only writes false when its injected file reader
10675
+ returns undefined or throws, and the server adapter hands back the request's own REQUIRED `content`
10676
+ field — so it can never be absent. Empty text, `{}`, and a file with no allow bucket all still report
10677
+ `found: true` (they simply yield no candidates). Do NOT use `found` as "did this layer contribute
10678
+ rules" — read `candidates` for that. The field is kept because it is part of core's preview shape
10679
+ (it only discriminates for a host whose reader touches a real filesystem).
10680
+ additionalProperties: false
10681
+ required: [path, layer, found]
10682
+ properties:
10683
+ path: { type: string }
10684
+ layer: { $ref: '#/components/schemas/CcImportSettingsLayer' }
10685
+ found: { type: boolean }
10686
+
10687
+ CcImportLayer:
10688
+ type: object
10689
+ description: >
10690
+ One settings layer submitted to rulesCcImportPrepare. The CONTENT is submitted by the caller because a
10691
+ cloud deployment has no access to the user's filesystem — that is the deployment shape, not a bypass:
10692
+ what is read is still only the allow bucket.
10693
+
10694
+ 🔴 `root` MUST be non-empty (an empty prefix contains every cwd — see RuleScope). Its TRUTHFULNESS is
10695
+ deliberately NOT validated: the server has no such tree to check against, and pretending to validate
10696
+ would be a lie. It is the user's own label for "which cwds should this rule be live under".
10697
+ # 封闭:server 侧 zod 是 `.strict()`(把 `content` 拼成 `contents` 的客户端应当场 400,而不是拿到
10698
+ # 一份「零候选」的预览去纳闷)。
10699
+ additionalProperties: false
10700
+ required: [layer, path, root, content]
10701
+ properties:
10702
+ layer: { $ref: '#/components/schemas/CcImportSettingsLayer' }
10703
+ path: { type: string, minLength: 1, maxLength: 1024 }
10704
+ root: { type: string, minLength: 1, maxLength: 1024 }
10705
+ 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).' }
10706
+
10707
+ CcImportPreview:
10708
+ type: object
10709
+ description: >
10710
+ What the import WOULD do, shown before anyone confirms (core `ImportPreview`).
10711
+
10712
+ `uncovered` is a TWO-KEY RECORD, not a list: the two layers this version does not read are named in the
10713
+ TYPE, so an empty list, a missing member or a duplicate is not expressible. A preview that reported only
10714
+ the layers it read would be claiming a completeness it does not have.
10715
+ additionalProperties: false
10716
+ required: [candidates, skipped, layers, uncovered]
10717
+ properties:
10718
+ candidates:
10719
+ type: array
10720
+ items: { $ref: '#/components/schemas/RuleCandidate' }
10721
+ description: 'What would be persisted, in redemption order.'
10722
+ skipped:
10723
+ type: array
10724
+ items: { $ref: '#/components/schemas/CcImportSkippedRule' }
10725
+ description: 'Entries that did not become candidates. `reason` is PROSE, not a code — see the schema.'
10726
+ layers:
10727
+ type: array
10728
+ items: { $ref: '#/components/schemas/CcImportLayerReport' }
10729
+ uncovered:
10730
+ type: object
10731
+ additionalProperties: false
10732
+ required: [flagSettings, policySettings]
10733
+ properties:
10734
+ flagSettings: { type: string, enum: [not-imported-v1] }
10735
+ policySettings: { type: string, enum: [not-imported-v1] }
10736
+
10737
+ CcImportPrepareResult:
10738
+ type: object
10739
+ description: >
10740
+ The 200 body of rulesCcImportPrepare. NO RULE is in the store yet — the writer is only reached by
10741
+ rulesCcImportRedeem. (A pending approval record and this ticket ARE durable already; see the endpoint
10742
+ description — prepare is not idempotent.)
10743
+ additionalProperties: false
10744
+ required: [preview, ticket, expiresAtMs]
10745
+ properties:
10746
+ preview: { $ref: '#/components/schemas/CcImportPreview' }
10747
+ ticket:
10748
+ type: string
10749
+ description: >
10750
+ The redemption ticket: principal-bound, expiring, atomically single-use, and payload-bound (it pins
10751
+ a digest of the candidate set at mint time, so tampering with the record and redeeming an old ticket
10752
+ fails an equality rather than relying on "only we can write the record"). NOT a cacheable credential;
10753
+ never hand it to another user.
10754
+ expiresAtMs: { type: integer, description: 'Absolute server-clock expiry (the window is 10 minutes — one interaction, not a session).' }
10755
+
10756
+ CcImportResult:
10757
+ type: object
10758
+ description: >
10759
+ What the import ACTUALLY did (core `ImportResult`) — a different moment and a different contract from
10760
+ the preview, because dedup, concurrency and redemption-time validation can each move an entry between
10761
+ the two.
10762
+
10763
+ ⚠️ `skippedAtRedeem` is empty on every success arm of THIS lane: the server treats a non-empty one as
10764
+ INDETERMINATE, releases the claim and answers 503 `state.rule_import_retry` instead of returning a 200
10765
+ that quietly means "half of it did not land". The field stays on the shape because it is core's.
10766
+ additionalProperties: false
10767
+ required: [persisted, deduped, skippedAtRedeem, rev]
10768
+ properties:
10769
+ persisted:
10770
+ type: array
10771
+ items: { $ref: '#/components/schemas/RuleCandidate' }
10772
+ deduped:
10773
+ type: array
10774
+ items: { $ref: '#/components/schemas/RuleCandidate' }
10775
+ description: 'Already in the store — not a failure.'
10776
+ skippedAtRedeem:
10777
+ type: array
10778
+ items: { $ref: '#/components/schemas/CcImportSkippedRule' }
10779
+ rev: { type: integer, description: 'The rule store''s monotonic revision after the batch.' }
10780
+
10781
+ CcImportRedeemResult:
10782
+ type: object
10783
+ description: 'The 200 body of rulesCcImportRedeem.'
10784
+ additionalProperties: false
10785
+ required: [result]
10786
+ properties:
10787
+ result: { $ref: '#/components/schemas/CcImportResult' }
10788
+
10789
+ # ── design/203 §2 撤销面(server 7.12.0)—— 列举行 + 两口的 200 体 ────────────────────────────────
10790
+ # 行形逐字段照 server `src/rules-consent.ts` 的 `PersistedRuleWireRow`(它自己逐字段照 core 的
10791
+ # `PersistedAllowRule`,只把 `scope` 换成判别式串)。
10792
+
10793
+ RuleDot:
10794
+ type: object
10795
+ description: >
10796
+ The immutable causal identity of ONE add — a replica identity plus a monotonic counter (core
10797
+ `RuleDot`). Compared by IDENTITY, never by causal order: single dots minted on different replicas
10798
+ have no order to compare. Treat it as an opaque pair; it is not a timestamp and not a sort key.
10799
+ additionalProperties: false
10800
+ required: [actor, counter]
10801
+ properties:
10802
+ actor: { type: string, description: 'The replica identity that minted this add.' }
10803
+ counter: { type: integer, description: 'That replica''s monotonic counter at mint time. Monotonic PER ACTOR only — never comparable across actors.' }
10804
+
10805
+ RuleAddOrigin:
10806
+ type: string
10807
+ description: >
10808
+ Where ONE add came from (core `RuleAddOrigin`). Stored PER ADD and never folded across dots — a
10809
+ folded provenance could not follow a dot that is later deleted on its own.
10810
+ `user` = minted from an approval card ("don't ask again"); `imported-cc` = redeemed through the
10811
+ CC-settings import lane; `starter` = a deployment-seeded rule.
10812
+ Closed vocabulary on the wire; read it with a default arm anyway (the owner is the engine).
10813
+ enum: [user, imported-cc, starter]
10814
+
10815
+ RuleAdd:
10816
+ type: object
10817
+ description: >
10818
+ ONE add of one logical rule, carrying its own dot and its own provenance (core `RuleAdd`). A rule is
10819
+ LIVE iff at least one of its adds survives the tombstones, which is why this is a SET and not a
10820
+ scalar: the same `(rule, scope)` can be approved several times, from different origins, at different
10821
+ moments. Folding it to a single "addedAt" would make "did I import this or click it?" unanswerable on
10822
+ a governance surface — which is the one place it matters.
10823
+ additionalProperties: false
10824
+ required: [dot, origin, createdAt]
10825
+ properties:
10826
+ dot: { $ref: '#/components/schemas/RuleDot' }
10827
+ origin: { $ref: '#/components/schemas/RuleAddOrigin' }
10828
+ createdAt: { type: string, description: 'When this ADD was recorded (engine-stamped ISO-8601). It dates the add, NOT the rule — a rule with several adds has several of these.' }
10829
+
10830
+ PersistedRule:
10831
+ type: object
10832
+ description: >
10833
+ One LIVE persisted allow rule as `GET /v1/rules` puts it on the wire (server `PersistedRuleWireRow`).
10834
+ Tombstones are already folded — every row here is live at `RuleListResult.rev`.
10835
+ 🔴 `scope` is the DISCRIMINANT STRING (`global` / `project:<root>`), NOT the `RuleScope` object the
10836
+ import lane speaks: this is the exact byte sequence the revoke body wants back, so echo it VERBATIM
10837
+ rather than re-serializing a parsed form (an equivalent-but-differently-spelled root matches nothing).
10838
+ The rule lane is ALLOW-only; deny/ask rules are the tightening direction and never appear here.
10839
+ additionalProperties: false
10840
+ required: [rule, scope, tool, match, command, adds]
10841
+ properties:
10842
+ rule: { type: string, description: 'Canonical rule text — `Bash(git status)` (exact) or `Bash(git status:*)` (prefix). Pass back VERBATIM to revoke.' }
10843
+ scope: { type: string, description: 'Scope discriminant string: `global`, or `project:<root>` with a NON-EMPTY root. Pass back VERBATIM to revoke.' }
10844
+ tool: { type: string, enum: [Bash], description: 'The one tool the v1 rule lane speaks for. The field exists so a later version can widen without a shape change — read it, do not assume it.' }
10845
+ match:
10846
+ type: string
10847
+ enum: [exact, prefix]
10848
+ description: '`exact` = this one command only; `prefix` = word-boundary prefix. (core reserves a `wildcard` form for v2 that no current version produces.)'
10849
+ command: { type: string, description: 'What the matcher actually compares against, parsed out of `rule` at mint time and stored so no consumer re-parses. Trust this + `match`, not your own parse of `rule`.' }
10850
+ adds:
10851
+ type: array
10852
+ description: 'Every LIVE add of this rule (see RuleAdd — a real set, deliberately not folded).'
10853
+ items: { $ref: '#/components/schemas/RuleAdd' }
10854
+
10855
+ RuleListResult:
10856
+ type: object
10857
+ description: >
10858
+ The 200 body of `rulesList` — one keyset page plus the revision it was read at.
10859
+ `nextCursor` ABSENT = this was the last page (there is no empty-string sentinel). `rev` is the rule
10860
+ bucket's OCC revision; the cursor is bound to it, so a page fetched with a stale cursor is REFUSED
10861
+ rather than silently restarted.
10862
+ additionalProperties: false
10863
+ required: [rules, rev]
10864
+ properties:
10865
+ rules:
10866
+ type: array
10867
+ description: 'Live rules, ordered `(scope, rule)` lexicographic — a server-decided deterministic order, since keyset paging depends on it.'
10868
+ items: { $ref: '#/components/schemas/PersistedRule' }
10869
+ rev: { type: integer, description: 'The rule bucket''s OCC revision at read time. Bucket-LOCAL: two tenants each holding one rule both read `rev: 1`, so it is not a global clock and not comparable across principals.' }
10870
+ nextCursor: { type: string, description: 'Opaque base64url cursor for the next page. ABSENT ⇒ no further pages. Pass back VERBATIM; do not parse or mint one.' }
10871
+
10872
+ RuleRevokeRequest:
10873
+ type: object
10874
+ description: >
10875
+ The `DELETE /v1/rules` body. A rule is addressed BY CONTENT — there is no id anywhere in this face.
10876
+ Echo `rule` and `scope` VERBATIM from the listing: both are compared as opaque bytes, so an
10877
+ equivalent-but-differently-spelled scope root simply matches nothing (and answers a cheerful `no-op`).
10878
+ # 封闭:server 侧 zod 是 `.strict()` —— 把 `scope` 拼成 `scopes` 的客户端应当场知道,而不是
10879
+ # 拿到一个「删掉了 global 那条」的意外结果。
10880
+ additionalProperties: false
10881
+ required: [rule, scope]
10882
+ properties:
10883
+ rule: { type: string, minLength: 1, maxLength: 1024, description: 'The canonical rule text, VERBATIM as `RuleListResult.rules[].rule` gave it.' }
10884
+ scope: { type: string, minLength: 1, maxLength: 1088, description: 'The scope discriminant string — `global` or `project:<root>` with a NON-EMPTY root (an empty root would match every cwd). VERBATIM as the listing gave it.' }
10885
+ principal: { type: string, minLength: 1, maxLength: 190, description: 'OPERATOR-ONLY cross-tenant revoke. Absent (or your own principal) = revoke your own rule.' }
10886
+
10887
+ RuleRevokeResult:
10888
+ type: object
10889
+ description: >
10890
+ The 200 body of `rulesRevoke`. `removed` = a tombstone was written; `no-op` = nothing matched (an
10891
+ idempotent repeat, or it was never there — deliberately NOT a 404, which would be an existence
10892
+ oracle). BOTH arms carry `stillLive`, and the key set is identical on purpose so one consumer branch
10893
+ reads both.
10894
+ additionalProperties: false
10895
+ required: [status, rev, stillLive]
10896
+ properties:
10897
+ status:
10898
+ type: string
10899
+ enum: [removed, "no-op"]
10900
+ description: 'Whether this call wrote a tombstone. `no-op` says nothing about whether the rule EVER existed — do not render it as "not found".'
10901
+ rev: { type: integer, description: 'The rule bucket''s OCC revision after the operation.' }
10902
+ stillLive:
10903
+ type: boolean
10904
+ description: >
10905
+ 🔴 `true` = the rule is STILL LIVE despite this call — under add-wins, an approval recorded during
10906
+ the operation keeps it alive. NEVER tell a human "the rule is gone" without reading this. It rides
10907
+ the `no-op` arm too, because the engine answers `no-op` off its initial snapshot with no read-back,
10908
+ so a concurrent approval leaves the rule alive while the verb reports nothing to remove.
10909
+
10910
+ 🔴 THE TWO TRUTH VALUES ARE NOT EQUALLY STRONG, AND THE ASYMMETRY IS IN THE ENGINE, NOT HERE.
10911
+ `true` is positive evidence: something was observed alive. `false` is only "no evidence it
10912
+ survived" — on the `removed` arm the engine performs the confirming read-back inside a `catch`
10913
+ that swallows a failure and reports `false` (core `removePersistedRule`), so a degraded store
10914
+ yields a byte-identical `{status:"removed", stillLive:false}` to a genuinely verified removal.
10915
+ The `no-op` arm does NOT share that hole: the server does that read-back itself and lets a failure
10916
+ surface as a 500 rather than invent an answer. So: act on `true`; when it MATTERS that a rule is
10917
+ really gone (an operator revoking a standing allow), confirm with `GET /v1/rules` instead of
10918
+ treating `false` as proof. Registered upstream — an indeterminate outcome should be its own state
10919
+ rather than folding into `false`.
10920
+
10921
+ # ═══════════════════════════════════════════════════════════════════════════════════════════════
10922
+ # design/183 —— 身份收编(form b:纯身份重绑)。形逐字段照 server `src/adoption/wire.ts` 的 zod
10923
+ # (那份 schema 自陈是「跨仓 wire 契约,下游按字段名逐字钉围栏」)。
10924
+ # ═══════════════════════════════════════════════════════════════════════════════════════════════
10925
+
10926
+ AdoptionRequest:
10927
+ type: object
10928
+ description: 'The POST /v1/adoption body.'
10929
+ additionalProperties: false # server 侧 `.strict()`:多余键是调用方错误,当场 400(不静默吞)
10930
+ required: [fromPrincipal, toPrincipal]
10931
+ properties:
10932
+ 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).' }
10933
+ 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.' }
10934
+
10935
+ AdoptionSource:
10936
+ type: object
10937
+ description: 'Where the adoption came from. Form b has exactly one shape — a named principal.'
10938
+ additionalProperties: false
10939
+ required: [kind, principal]
10940
+ properties:
10941
+ kind: { type: string, enum: [principal] }
10942
+ principal: { type: string, minLength: 1 }
10943
+
10944
+ AdoptionLegAction:
10945
+ type: string
10946
+ enum: [bucket-rebind, row-rewrite, carried, none, reset]
10947
+ description: 'The per-store migration verb (closed set; form b only ever emits the first two plus `none`).'
10948
+
10949
+ AdoptionLeg:
10950
+ type: object
10951
+ description: 'One migration leg''s receipt.'
10952
+ additionalProperties: false
10953
+ required: [store, action]
10954
+ properties:
10955
+ store: { type: string, minLength: 1, description: 'Stable leg id = `<table>#<identity column>` (the server schema''s real names — reconcile against it).' }
10956
+ action: { $ref: '#/components/schemas/AdoptionLegAction' }
10957
+ rows:
10958
+ type: integer
10959
+ minimum: 0
10960
+ description: >
10961
+ Rows this leg changed when it RAN. 🔴 NOT a per-request delta and NOT a late-sweep delta: legs are
10962
+ only ever exposed inside `immutableReport`, so on a terminal arc every re-run replays the FROZEN
10963
+ first-run counts (a non-zero value here says nothing about what the current request did). On a
10964
+ `stalled` receipt it is the current row's reading, which can still move.
10965
+ quarantined: { type: integer, minimum: 0 }
10966
+
10967
+ AdoptionConfigEntry:
10968
+ type: object
10969
+ description: >
10970
+ One affected DEPLOYMENT configuration. 🔴 `migrated` is always false on the server half: these live in
10971
+ OTHER deployment units (BFF / cli / env / core declarations) that the adoption cannot reach — and a
10972
+ configuration one cannot change must not be reported as changed. The list being present, plus
10973
+ `migrated:false`, plus an unsettled account, is together the evidence for "configuration does not
10974
+ migrate, stated honestly".
10975
+ additionalProperties: false
10976
+ required: [deployment, key, requiredValue, migrated, ack, witnessedAtMs]
10977
+ properties:
10978
+ deployment: { type: string, minLength: 1 }
10979
+ key: { type: string, minLength: 1 }
10980
+ requiredValue: { type: string }
10981
+ migrated: { type: boolean }
10982
+ 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).' }
10983
+ witnessedAtMs:
10984
+ type: [integer, "null"]
10985
+ description: 'When a consumer''s runtime typed receipt actually read `requiredValue` back. 🔴 `null` = not witnessed = NOT settled.'
10986
+
10987
+ AdoptionNotMigratedFace:
10988
+ type: string
10989
+ enum: [usage-window, cost-quota, rate-limit, approval-exemption-grantor, approval-ask-decision-actor, image-bake-requested-by]
10990
+ description: >
10991
+ FROZEN machine-readable vocabulary of the faces the server deliberately does not migrate. Renaming or
10992
+ removing a member is a wire break; adding one is a new behaviour face. Consumers assert POSITIVELY on
10993
+ these so that "not migrated by design" and "forgotten" stay distinguishable in a black box.
10994
+
10995
+ AdoptionNotMigrated:
10996
+ type: object
10997
+ description: 'One positive "by design, not migrated" declaration.'
10998
+ additionalProperties: false
10999
+ required: [face, ruling, reason]
11000
+ properties:
11001
+ face: { $ref: '#/components/schemas/AdoptionNotMigratedFace' }
11002
+ 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.' }
11003
+ reason: { type: string, minLength: 1 }
11004
+
11005
+ AdoptionReport:
11006
+ type: object
11007
+ description: >
11008
+ The adoption report.
11009
+
11010
+ 🔴 "IMMUTABLE" HOLDS ONLY ONCE `status` IS `adopted`. The frozen row is written once at the final phase
11011
+ and replayed byte-for-byte thereafter, never recomputed — that byte-identity IS the idempotency
11012
+ criterion. But a `stalled` receipt has no such row yet, so the server PROJECTS a same-shaped report
11013
+ from the CURRENT row (`atMs` = the row's updated-at, configs/legs = the current columns). That
11014
+ projection is provisional: the next read can differ in every field. Do NOT persist a `stalled`
11015
+ report as audit evidence — take the frozen one after `status === "adopted"`. The two share one shape
11016
+ by upstream ruling (there is only one envelope); the discriminator is `status`, not the field name.
11017
+ additionalProperties: false
11018
+ required: [adoptionId, form, from, toPrincipal, atMs, affectedDeploymentConfigs, legs, notMigratedByDesign]
11019
+ properties:
11020
+ adoptionId: { type: string, minLength: 1 }
11021
+ 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.' }
11022
+ from: { $ref: '#/components/schemas/AdoptionSource' }
11023
+ toPrincipal: { type: string, minLength: 1 }
11024
+ atMs: { type: integer, minimum: 0 }
11025
+ affectedDeploymentConfigs:
11026
+ type: array
11027
+ items: { $ref: '#/components/schemas/AdoptionConfigEntry' }
11028
+ legs:
11029
+ type: array
11030
+ items: { $ref: '#/components/schemas/AdoptionLeg' }
11031
+ notMigratedByDesign:
11032
+ type: array
11033
+ items: { $ref: '#/components/schemas/AdoptionNotMigrated' }
11034
+
11035
+ AdoptionCurrent:
11036
+ type: object
11037
+ description: 'Recomputed on every read (the counterpart to AdoptionReport''s frozen history — do not infer one from the other).'
11038
+ additionalProperties: false
11039
+ required: [outstandingConfigs, quarantined, residualSourceRows]
11040
+ properties:
11041
+ outstandingConfigs:
11042
+ type: array
11043
+ items: { type: string }
11044
+ description: 'Config entries whose `witnessedAtMs` is null, as `<deployment>:<key>`. Empty = the account is settled.'
11045
+ 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.' }
11046
+ residualSourceRows:
11047
+ type: integer
11048
+ minimum: 0
11049
+ description: >
11050
+ Rows STILL under the old identity, recomputed on every read. AGGREGATION: counted PER TABLE with
11051
+ the table's identity axes unioned, so a row carrying the old identity on two or three axes counts
11052
+ ONCE; the per-table counts are then summed. It is deliberately NOT a sum over legs — reconciling
11053
+ this number against per-leg `rows` will not add up.
11054
+
11055
+ 🔴 Load-bearing: adoption does NOT fence ordinary writers ("stop the engine first" is an operator
11056
+ precondition, not a machine guarantee), so a caller still injecting the old identity keeps writing
11057
+ rows after the terminal state while the idempotent short-circuit keeps answering `adopted`. So `> 0`
11058
+ means "rows may still sit under the old identity, OR the count could not be taken" — either way, GO
11059
+ LOOK. It is NOT proof that the deployment is actively writing. A POST also sweeps those late rows in
11060
+ and re-reports this; a GET does not sweep (though it does resume an in-flight arc — see the GET
11061
+ description).
11062
+
11063
+ ⚠️ `1` IS AMBIGUOUS. When the count itself fails (query error / parse failure) the server reports
11064
+ `1` fail-closed rather than `0`, because `0` is the literal assertion "everything is clean" and it
11065
+ has not earned the right to say that. So `1` may mean "one residual row" OR "could not measure";
11066
+ the wire cannot tell them apart (the server log carries `adoption_residual_count_failed`). Use it
11067
+ as intended — `> 0` ⇒ GO LOOK — but never feed `=== 1` to a report as the exact number of rows.
11068
+
11069
+ AdoptionReceipt:
11070
+ type: object
11071
+ description: >
11072
+ The ONE adoption receipt envelope — the same SHAPE for the first success and for every idempotent
11073
+ re-run. No second receipt shape exists anywhere.
11074
+
11075
+ ⚠️ Same shape carries NO byte-identity guarantee: `current` is recomputed on every call (residual
11076
+ count, config ledger) so two reads may match or may differ, and before the terminal state
11077
+ `immutableReport` is itself a provisional projection. The one thing guaranteed frozen is a TERMINAL
11078
+ `immutableReport`.
11079
+
11080
+ 🔴 `adoptionId` is at the TOP LEVEL (the server sends `{adoptionId, ...receipt}`), carrying the same
11081
+ value as `immutableReport.adoptionId`.
11082
+ 🔴 `status` is a TWO-WORD closed set. `rejected` is deliberately NOT a member: a refused adoption never
11083
+ produces a receipt, it answers a typed 409 — otherwise "refused" and "succeeded" would share one 200
11084
+ envelope and a consumer could only guess by reading fields.
11085
+ 🔴 `status` is ALSO the precondition for reading `immutableReport`: it is frozen only once `adopted`;
11086
+ on `stalled` it is a provisional projection of the current row (see AdoptionReport).
11087
+ additionalProperties: false
11088
+ required: [adoptionId, status, immutableReport, current]
11089
+ properties:
11090
+ adoptionId: { type: string, minLength: 1 }
11091
+ 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.' }
11092
+ immutableReport:
11093
+ allOf: [{ $ref: '#/components/schemas/AdoptionReport' }]
11094
+ 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.'
11095
+ current: { $ref: '#/components/schemas/AdoptionCurrent' }