@sema-agent/sdk 8.1.0 → 8.3.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
@@ -1453,7 +1453,7 @@ paths:
1453
1453
  required: [sessionId, leafId]
1454
1454
  properties:
1455
1455
  sessionId: { type: string }
1456
- leafId: { type: string, nullable: true, description: "null = session exists but has no entries yet." }
1456
+ leafId: { type: ["string", "null"], description: "null = session exists but has no entries yet." }
1457
1457
  '304': { description: 'Leaf unchanged since the presented ETag (no body).' }
1458
1458
  '401': { $ref: '#/components/responses/Unauthorized' }
1459
1459
  '404': { $ref: '#/components/responses/NotFound' }
@@ -3358,31 +3358,21 @@ paths:
3358
3358
  decision: { type: string, enum: [allow, allow_session, deny] }
3359
3359
  updatedInput: { description: 'ctrl+g edited tool args (replaces the asked args on allow).' }
3360
3360
  persistRule:
3361
- type: object
3362
- # 刻意**不**封闭:server 的 `parseToolApprovalResponse` 只读 `rule`,对象里多给的键它**忽略**
3363
- # (与 `updatedInput` 同一条宽收姿势)。写 `additionalProperties:false` 会让照 spec 生成的
3364
- # 客户端拒发一个 server 其实会受理的请求 —— 那是我们自己造的漂移,不是上游的约束。
3365
- required: [rule]
3366
- properties:
3367
- rule: { type: string, minLength: 1, maxLength: 512, description: 'Non-empty (the server parser rejects an empty string); at most MAX_RULE_TEXT_CHARS = 512.' }
3361
+ allOf: [{ $ref: '#/components/schemas/PersistRuleSelection' }]
3368
3362
  description: >
3369
- server #154 车二, ADDITIVE — the person ticked "don't ask again": echo back, VERBATIM, the
3370
- `rule` of the chosen entry of the frame's `ruleSuggestions`. Allow-family only (on `deny`
3371
- the server tolerantly ignores it, same posture as `updatedInput`).
3372
-
3373
- 🔴 A CANDIDATE, NOT FREE TEXT: the server locates this string in the candidate table the
3374
- engine minted and refuses if it does not match (`ruleRefusal: "rule_not_offered"`). Do not
3375
- hand-build it, re-case it or append `:*` — take it from the frame.
3376
- 🔴 Together with `updatedInput` the persistence is refused (`rule_input_edited`): the call
3377
- that takes effect is the edited one while the candidates came from the ORIGINAL command.
3378
- The decision itself still stands.
3379
- ⚠️ If the frame carried no `ruleSuggestions`, do not send this key — there is nothing
3380
- redeemable for THIS card and the answer is necessarily `rulePersisted:false` +
3381
- `rule_lane_unavailable` (the missing material short-circuits BEFORE any candidate
3382
- comparison, so it is never `rule_not_offered`). Absence has THREE possible causes — no rule
3383
- store wired, the engine minted no candidate for this command (compound / redirect /
3384
- substitution), or the command bytes were unreadable — so do NOT diagnose the deployment's
3385
- rule lane from one frame or from that refusal.
3363
+ The person ticked "don't ask again". TWO MUTUALLY EXCLUSIVE ARMS — the candidate/edited arm
3364
+ `{ rule, edited? }` and the batch arm `{ batchOfferIndex }`; the shapes, the server's four
3365
+ verbatim 400 texts, the two capability bits to probe first and the `deny` posture are on
3366
+ the PersistRuleSelection schema. Allow-family only (on `deny` the whole field is ignored).
3367
+
3368
+ ⚠️ If the frame carried neither `ruleOffers` nor `ruleSuggestions`, do not send this key —
3369
+ there is nothing redeemable for THIS card and the answer is necessarily
3370
+ `rulePersisted:false` + `rule_lane_unavailable` (the missing material short-circuits BEFORE
3371
+ any candidate comparison, so it is never `rule_not_offered`). Absence has several causes —
3372
+ no rule store wired, the engine minted no offer for this command (compound / redirect /
3373
+ substitution), the command bytes were unreadable, or this ask belongs to one of the seven
3374
+ families that never get offers — so do NOT diagnose the deployment's rule lane from one
3375
+ frame or from that one refusal.
3386
3376
  note:
3387
3377
  type: string
3388
3378
  maxLength: 2048
@@ -6603,6 +6593,39 @@ components:
6603
6593
  `noteRecorded` (a live-card-only deployment has no row to write, and a flapping store cannot write
6604
6594
  one; both answer `false` while the decision itself still stands). This bit only says "this build
6605
6595
  knows the key" — same family as `permissionRulesRevoke`, deliberately gated on no facility.
6596
+ respondFreeFormRules:
6597
+ type: boolean
6598
+ description: >
6599
+ server >= 7.44.0 (#340), hard-coded true in the capability object — the VERSION PROBE for the
6600
+ free-text arm of POST /v1/tool-approvals/{id}/respond (`persistRule.edited: true`): the rule to
6601
+ persist is text the person HAND-EDITED on the card, not one of the engine's candidates.
6602
+
6603
+ PROBE IT BEFORE RENDERING THE EDITABLE INPUT. The live respond body validator is a NON-STRICT
6604
+ hand-written parser, so an older worker DROPS the unknown `edited` key and judges the text AS A
6605
+ CANDIDATE, answering `rule_not_offered` — honest, but a person reads it as "I typed it wrong".
6606
+ Absent means an older worker: do not render the affordance.
6607
+
6608
+ IT DOES NOT PROMISE THIS ONE WILL LAND (deliberately no facility predicate, same family as
6609
+ `approvalDecisionNote`): that is per-call and answered by `rulePersisted` / `ruleRefusal` —
6610
+ `edit-unsupported` (this card has no binding anchor, STOP rendering the box) versus
6611
+ `edit-rejected` (fix it and try again). Whether the LANE exists at all is `permissionRules`,
6612
+ computed per caller; read the two bits together.
6613
+ respondBatchRuleOffers:
6614
+ type: boolean
6615
+ description: >
6616
+ server >= 7.46.0 (design/377), hard-coded true — the VERSION PROBE for the batch arm of the same
6617
+ endpoint (`persistRule.batchOfferIndex`): the person ticked a `kind: "batch"` offer, and the
6618
+ selection key is that offer's index in the frame's `ruleOffers`.
6619
+
6620
+ PROBE IT BEFORE RENDERING A REDEEMABLE BATCH AFFORDANCE: an older worker drops the unknown key and
6621
+ then fails the `rule` shape check with a 400 — honest, but it cannot tell a batch was ticked. With
6622
+ the bit absent, render batch offers read-only.
6623
+
6624
+ Same posture as above: it only says "this binary knows this key". Whether THIS redemption lands is
6625
+ answered per call by `rulePersisted` / `ruleRefusal`, with `persistedRules` on success and (server
6626
+ >= 7.48.0) the per-entry anchors `persistedRuleAnchors` — that additive key deliberately gets NO
6627
+ second capability bit, since it rides the same batch redemption channel and a consumer just reads
6628
+ whether it is there.
6606
6629
  promptSuggestions: { type: boolean, description: "Follow-up prompt suggestions are generated." }
6607
6630
  modelUsage:
6608
6631
  type: boolean
@@ -6661,6 +6684,217 @@ components:
6661
6684
  and the history PULL answers the no-oracle 404 `not_found.blob`, while the REST of the sync
6662
6685
  surface keeps working.
6663
6686
 
6687
+ # ─── S-139(sdk 8.3.0):对 server 7.60.0 的整体重对账补齐的 19 位 ───────────────────────
6688
+ # 行号 = server 7.60.0 树的 `src/http/routes/capabilities.ts`。语义与谓词的全文在
6689
+ # `packages/sdk/src/types.ts` 的 `Capabilities` 同名成员上(逐条亲读 server 源码转述);
6690
+ # 这里只留 codegen 需要的形与一句定性。机械对账门 = `test/server-keyset-parity.test.ts`。
6691
+ deviceExecutor:
6692
+ description: >
6693
+ device lane (capabilities.ts:162). Object OR `false` — same tri-state family as `fleet`.
6694
+ Predicate = `deps.deviceHub` present, byte-identical to the `GET /v1/device/ws` upgrade
6695
+ handler's mount condition. `wsPath` is deliberately on the wire (hard-coding it downstream
6696
+ would turn a path change into a cross-repo breaking change). Device COUNT / online state are
6697
+ deliberately NOT advertised (that is the `/v1/devices/*` admin face).
6698
+ oneOf:
6699
+ - type: boolean
6700
+ enum: [false]
6701
+ - type: object
6702
+ required: [enabled, protocolVersion, maxInflightPerDevice, wsPath]
6703
+ additionalProperties: true
6704
+ properties:
6705
+ enabled: { type: boolean, enum: [true] }
6706
+ protocolVersion: { type: integer }
6707
+ maxInflightPerDevice: { type: integer }
6708
+ wsPath: { type: string }
6709
+ memoryEngine:
6710
+ description: >
6711
+ Memory ENGINE posture (capabilities.ts:210 = `projectMemoryEngineCapability`). Object = the
6712
+ memory face is really lit, `false` = dark. NOT the same thing as `memory`/`memoryWrite` (those
6713
+ advertise the RETIRED MemoryStore HTTP verbs and stay false forever) — the engine has NO HTTP
6714
+ verbs, so a true here does NOT mean there are memory read/write endpoints to call.
6715
+ `vectorMode: null` = this leg has no rung indicator (the `file` engine), NOT "the rung is null".
6716
+ oneOf:
6717
+ - type: boolean
6718
+ enum: [false]
6719
+ - type: object
6720
+ required: [backend, vectorMode]
6721
+ additionalProperties: true
6722
+ properties:
6723
+ backend: { type: string, enum: [file, pg, tidb] }
6724
+ # 🔴 openapi **3.1**:可空用 `type: [T, "null"]`,`nullable` 不是 3.1 关键字(会被静默忽略)。
6725
+ # `null` 也必须进 enum,否则真校验器把 file 引擎的常态答判非法。
6726
+ vectorMode: { type: ["string", "null"], enum: [native, portable, lexical, null] }
6727
+ sql:
6728
+ type: ["object", "null"]
6729
+ additionalProperties: true
6730
+ description: >
6731
+ This deployment's SQL transaction read semantics (capabilities.ts:216 =
6732
+ `projectSqlEngineCapability`), read back AFTER connection initialization set it — same source as
6733
+ the operator `GET /v1/diagnostics/wiring` `sqlEngine` section. `null` = NO SQL backend here
6734
+ (env-only worker / local file backend), NOT "could not read it". `txnMode: null` = this engine
6735
+ has no such indicator, NOT "optimistic".
6736
+ required: [engine, isolation, txnMode]
6737
+ properties:
6738
+ engine: { type: string, enum: [tidb, innodb, pg] }
6739
+ isolation: { type: string }
6740
+ txnMode: { type: ["string", "null"], enum: [pessimistic, null] }
6741
+ sessionBackgroundable:
6742
+ type: boolean
6743
+ description: >
6744
+ The REMOTE lane can detach (capabilities.ts:233 = `Boolean(deps.runStore)`): `POST
6745
+ /v1/tasks/stream` with `x-detach-on-disconnect: true` survives client disconnect; come back via
6746
+ the `X-Task-Id` response header and `GET /v1/runs/{id}` + `/events`. Deployment half only — the
6747
+ per-request half (caller must pass `sessionId`) is not in this bit. Scope is the REMOTE lane;
6748
+ never repurpose it to disable local backgrounding.
6749
+ runMemoryCaptureOptOut:
6750
+ type: boolean
6751
+ description: >
6752
+ `POST /v1/runs/{id}/memory/capture-optout` mid-run flip verb is present (capabilities.ts:237 =
6753
+ `Boolean(deps.runStore)`). Absent ⇒ do not render the affordance. True only promises the
6754
+ ENDPOINT exists, not that THIS run is live (non-live ⇒ 409 with a signposting body).
6755
+ memoryBundle:
6756
+ type: boolean
6757
+ description: >
6758
+ Governance carry-out bundle pair `POST /v1/memory/{export,import}` (operator lane) —
6759
+ capabilities.ts:261, two conjuncts: both engine seams wired AND `operatorPrincipals` NON-EMPTY
6760
+ (an empty roster makes those routes 403 for every identity). True does not promise the backend
6761
+ has the bundle composite face (core then refuses loudly with `memory.export_incomplete`).
6762
+ memoryCompliance:
6763
+ type: boolean
6764
+ description: >
6765
+ Provenance / erasure compliance pair `GET /v1/memory/entries/{entryId}/provenance` and
6766
+ `POST /v1/memory/erase` (operator lane) — capabilities.ts:273, same two-conjunct family as
6767
+ `memoryBundle`. Deliberately NOT the same source as `memoryBundle` (a deployment can have the
6768
+ bundle composite face without control-plane ownership, and vice versa).
6769
+ memoryOrigin:
6770
+ type: boolean
6771
+ description: >
6772
+ External-origin marking face (three routes under `/v1/memory/origin/*`, operator lane) —
6773
+ capabilities.ts:287, same two-conjunct family. Same predicate as `memoryCompliance` TODAY but a
6774
+ deliberately separate bit (two products; a deployment may want only one). True does not promise
6775
+ the `GET …/external` answer is complete — there is no scope enumeration face, so an empty answer
6776
+ must never be read as "the store is clean".
6777
+ memoryConsolidationDriver:
6778
+ type: boolean
6779
+ description: >
6780
+ Consolidation valve pair (`POST /v1/admin/memory/consolidation/run`, `GET
6781
+ /v1/admin/memory/consolidation`, operator lane) — capabilities.ts:301. FALSE ⇒ those routes are
6782
+ **404** (whole domain unmounted), NOT 501: do not branch on 501 here. Contrast
6783
+ `memoryOptOutGrant`, whose false arm IS a 501.
6784
+ memoryOptOutGrant:
6785
+ type: boolean
6786
+ description: >
6787
+ memory-capture opt-out GRANT table admin (four routes under `/v1/admin/memory-optout`,
6788
+ operator-only) — capabilities.ts:307. FALSE ⇒ **501** `capability.memory_optout_grant_required`
6789
+ (change the deployment shape), as opposed to `memoryConsolidationDriver`'s 404 (turn the knob on).
6790
+ permissionModeAuto:
6791
+ type: object
6792
+ additionalProperties: true
6793
+ description: >
6794
+ Default-state disclosure for `permissionMode: "auto"` (capabilities.ts:463; adjudicated by
6795
+ `src/auto-mode-face.ts`). The parts are deliberately NOT folded into one boolean — "this binary
6796
+ does not know auto" and "it knows auto but this box has no entitlement source" would collapse to
6797
+ the same false, and a shell must do different things for those. `reason` is present ONLY when
6798
+ `armed` is false; read its vocabulary as an OPEN set (server already grew it from four words to
6799
+ six). `model` is omitted when the classifier route cannot be resolved (the server logs the
6800
+ engine's refusal rather than 500-ing the read face). `?permissionMode=<five words>` folds the
6801
+ caller's INTENT into the verdict; anything outside the five words is a 400
6802
+ `request.field_invalid`.
6803
+ # 🔴 required 只有**三位**:武装三键(intentArming / armed / reason)是 server >= 7.57.0 (S-80)
6804
+ # 才有的,而本包的支持地板是 3.0.0 => every 7.x <= 7.56.0 server is inside the promised face and
6805
+ # emits ONLY these three (verified verbatim against server tags v7.50.0 / v7.56.0). Requiring the
6806
+ # arming triple would make a SUPPORTED server's legitimate response invalid.
6807
+ required: [accepted, classifierSeat, entitlementSource]
6808
+ properties:
6809
+ accepted: { type: boolean, enum: [true] }
6810
+ classifierSeat: { type: boolean }
6811
+ entitlementSource: { type: boolean }
6812
+ intentArming: { type: boolean, description: 'server >= 7.57.0 only. ABSENT together with `armed`/`reason` on <= 7.56.0.' }
6813
+ armed:
6814
+ type: boolean
6815
+ description: >
6816
+ server >= 7.57.0 only. Read as THREE states, not two: true = will arm; false = will not, and
6817
+ `reason` is then present; ABSENT = an older server that does not report arming at all — never
6818
+ fold that absence into false (it would render a 7.50 box that IS arming as "auto == default").
6819
+ reason:
6820
+ type: string
6821
+ enum: [mode_not_auto, deployment_incapable, org_denied, local_denied, settings_denied, resolver_fault]
6822
+ model: { type: string }
6823
+ readFace:
6824
+ type: ["string", "null"]
6825
+ enum: [open, roots, null]
6826
+ description: >
6827
+ The EFFECTIVE READ containment rung this deployment explicitly declared (capabilities.ts:484 =
6828
+ `deps.config.readFace ?? null`; env `READ_FACE` or config-center's `readFace.face` — same config
6829
+ key, so this bit does not distinguish org-pushed from local). `null` = this deployment pinned
6830
+ NOTHING and the engine default takes over; the server deliberately does NOT fold `null` into
6831
+ `roots` (copying an upstream default onto the wire becomes a lie the day core changes it).
6832
+ Minimal disclosure — the deny table itself is never on the capability face. There is NO
6833
+ per-request `readFace` field on TaskRequest.
6834
+ callerCwd:
6835
+ type: boolean
6836
+ description: >
6837
+ Whether a caller-supplied `cwd` is actually HONORED (capabilities.ts:541 =
6838
+ `cwdHonored(config) || deviceCwdHonored(config)`) — single-user `host` lane OR the `device` lane.
6839
+ Byte-identical predicate to the submit-side gate in `boot/resolve-spec.ts`. Prefer this bit;
6840
+ when absent (older worker) fall back to `projectContext` — that is the verbatim pre-bit behavior.
6841
+ a2a:
6842
+ type: boolean
6843
+ description: >
6844
+ The A2A CLIENT read face `GET /v1/sessions/{id}/a2a` exists (capabilities.ts:555, unconditional
6845
+ `true` — like `mcp`): with no peers configured it returns an honest empty panel, so there is no
6846
+ 501 path to gate on. Deliberately a DIFFERENT predicate from `a2aInjection`.
6847
+ a2aInjection:
6848
+ type: boolean
6849
+ description: >
6850
+ This deployment HONORS a caller-supplied `body.a2aPeers` (capabilities.ts:556 =
6851
+ `a2aInjectionHonored(config)`; three vetoes owned by `task-a2a.ts`: single-user AND `a2a`
6852
+ unlocked AND compliance permits). FALSE is a FIELD gate, not a 4xx: the request still 200s and
6853
+ the field is warned + ignored — EXCEPT under a config lock, where submit refuses 400
6854
+ `config.locked_key`.
6855
+ a2aServe:
6856
+ type: boolean
6857
+ description: >
6858
+ Server-as-peer knob (capabilities.ts:564 = `config.a2aServe !== undefined`): true ⟺
6859
+ `GET /.well-known/agent-card.json` and `POST /v1/a2a` exist; false ⟺ both 404. Scope is ROUTE
6860
+ EXISTENCE, not "every method runs" — `message/send` / `tasks/get` additionally need a durable run
6861
+ store (without it they answer a named JSON-RPC `-32004`).
6862
+ agentRoster:
6863
+ type: boolean
6864
+ description: >
6865
+ `GET /v1/agents/roster` background-agent roster read face (capabilities.ts:640 =
6866
+ `Boolean(deps.backgroundAgentStore)`, byte-identical to that route's 501 gate). Scope is the
6867
+ ENUMERATION face (content-free projection); subagent OUTPUT bodies go through
6868
+ `/v1/runs/{id}/subagents/{handle}/output`, gated by `subagentOutput`.
6869
+ retention:
6870
+ type: ["object", "null"]
6871
+ additionalProperties: true
6872
+ description: >
6873
+ Managed-retention deployment facts (capabilities.ts:654). `null` = the sweep lane is off
6874
+ (default). `mode: audit-only` = it runs and judges but calls NO destructive method; `enforce` =
6875
+ it really deletes. The predicate looks only at config because a box with `intervalSec > 0` and no
6876
+ real executor CANNOT BOOT (`boot/retention-lane.ts` refuses at startup).
6877
+ required: [mode, maxAgeDays]
6878
+ properties:
6879
+ mode: { type: string, enum: [audit-only, enforce] }
6880
+ maxAgeDays: { type: integer }
6881
+ workflowsGate:
6882
+ type: object
6883
+ additionalProperties: true
6884
+ description: >
6885
+ Self-orchestration denial disclosure (capabilities.ts:739). The two parts are deliberately not
6886
+ folded: `engineCan` = core's `workflowsCapability`; `denial` = the DEPLOYMENT-level admission
6887
+ refusal (closed set, today's only member `entitlement_resolver_absent`), `null` = this layer does
6888
+ not refuse. Non-null means the engine is there but this deployment can grant it to NO principal
6889
+ (half-configured) — signpost the OPERATOR, do not tell the user to retry. `denial: null` does NOT
6890
+ mean "you are authorized" (that is per-principal). `workflows` is the PRODUCT of the two factors.
6891
+ required: [engineCan, denial]
6892
+ properties:
6893
+ engineCan: { type: boolean }
6894
+ denial:
6895
+ type: ["string", "null"]
6896
+ enum: [entitlement_resolver_absent, null]
6897
+
6664
6898
  SkillSpec:
6665
6899
  type: object
6666
6900
  description: A per-request skill (passed as an object; core-native TaskSpec.skills shape).
@@ -8023,6 +8257,27 @@ components:
8023
8257
  display order; an EXACT entry, when present, is always first. core contract caps candidates at 2;
8024
8258
  the server enforces a tolerance cap of 4 on read-back — lay out for 4. Absent = pre-column row
8025
8259
  (parked before the column existed) OR no candidates; render both as "no candidates", never an error.
8260
+ ruleOffers:
8261
+ type: array
8262
+ minItems: 1
8263
+ items: { $ref: '#/components/schemas/RuleOffer' }
8264
+ description: >
8265
+ server >= 7.46.0 (core 5.58.0, design/375) — the discriminated-union successor of the retired
8266
+ `ruleSuggestions` above on the DURABLE queue row (core `PendingAction.ruleOffers`, the same
8267
+ contract as the sync leg's `AskRequest.ruleOffers`). A >= 7.46.0 row carries only the new key;
8268
+ pre-5.58 rows fail the new narrow read entry by entry and land as "no supply" (no compatibility
8269
+ read — a stale schema means re-ask).
8270
+
8271
+ 🔴 THE INDEX ON THIS LEG IS NOT A SELECTION KEY. This projection drops malformed offers one by one
8272
+ and COMPACTS, so an excision shifts every later index forward. That is only sound because this leg
8273
+ has NO redemption mouth (POST /v1/approvals/{sessionId}/decide takes no rule field). Using it as
8274
+ `persistRule.batchOfferIndex` would redeem a different rule than the one the person clicked. The
8275
+ live card leg is the opposite (pure prefix truncation, no per-entry drops) and is where redemption
8276
+ actually happens.
8277
+
8278
+ 🔴 DISPLAY / TRIAGE ONLY, and ABSENT MEANS THERE REALLY IS NO SUPPLY (rule lane unarmed, the
8279
+ command yields no rule, no rule could silence this ask, or an old row) — never "the projection
8280
+ dropped it", which is precisely the bug this key exists to close. Never `null`, never `[]`.
8026
8281
  governanceForced:
8027
8282
  type: boolean
8028
8283
  enum: [true]
@@ -9889,6 +10144,38 @@ components:
9889
10144
  replica ⇒ redeemable; after a restart / on another replica that `approvalId` 404s and the person
9890
10145
  must use the durable decide leg, which carries NO rule field in v1 (its body is strict — an extra
9891
10146
  `persistRule` is a loud 400, not a silent drop).
10147
+ ruleOffers:
10148
+ type: array
10149
+ minItems: 1
10150
+ items: { $ref: '#/components/schemas/RuleOffer' }
10151
+ description: >
10152
+ server >= 7.46.0 (core 5.58.0, design/375), "tool_approval" only — the ENGINE-minted "don't ask
10153
+ again" OPTIONS for this ask, as a DISCRIMINATED UNION (`single` | `batch`). It is the BREAKING
10154
+ REPLACEMENT of `ruleSuggestions` above: a >= 7.46.0 engine mints the old key nowhere, there is no
10155
+ alias, and the two keys are declared side by side only so one type surface covers both generations.
10156
+
10157
+ 🔴 READ `kind` TO TELL THE ARMS APART, never a positional assumption. ORDER IS CONTRACT: a
10158
+ whole-string exact `single` is index 0 when present and a `batch` is always last; core's own
10159
+ cardinality is <= 2 while the server enforces a tolerance cap of 4.
10160
+
10161
+ 🔴 INDEX SEMANTICS DIFFER PER LEG, and only one leg can redeem. On THIS leg (and on the
10162
+ `approval_request` card and the durable replay of both) the server prefix-truncates and never drops
10163
+ an entry, so the index equals core's offer index and IS a legal selection key for
10164
+ `persistRule.batchOfferIndex`. On the durable queue row (PendingCheckpoint.ruleOffers) the server
10165
+ drops malformed entries and COMPACTS, so indexes shift there and that leg has no redemption mouth
10166
+ at all. A consumer that drops entries itself must keep every survivor's ORIGINAL wire index, or
10167
+ suppress its persistence actions entirely (core's normative clause) — a compacted index means the
10168
+ k-th option the person clicked is not the k-th rule the server redeems.
10169
+
10170
+ 🔴 PRESENCE IS THE PROMISE (same as `ruleSuggestions`): present only where a permission-rule store
10171
+ is really wired. The converse does not hold — a wired deployment still omits it for mandated /
10172
+ requiresRealApproval / shadowed / hook-produced / inherited-unresolved / ancestor-resolved /
10173
+ anonymous asks. Absence reads as "THIS CARD has no such option", never as "this deployment has no
10174
+ rule lane" (that is `capabilities.permissionRules`) and never as "your rule broke" (that is
10175
+ `persistedRuleShadowed`).
10176
+
10177
+ ⚠️ REDEMPTION SCOPE is the live respond leg keyed by this frame's `approvalId` only; a replayed
10178
+ offer on another replica is not a promise that it is still redeemable.
9892
10179
  persistedRuleShadowed:
9893
10180
  type: string
9894
10181
  description: >
@@ -10013,6 +10300,53 @@ components:
10013
10300
  parentToolCallId: { type: string }
10014
10301
  depth: { type: integer }
10015
10302
  agentName: { type: string }
10303
+ # ─── S-139(sdk 8.3.0):对 server 7.60.0 的整体重对账补齐的 9 位 ────────────────────────
10304
+ # 长 description 的单一真源在 `packages/sdk/src/resources/tool-approvals.ts` 的同名成员上;
10305
+ # 这里只留 codegen 需要的形与硬条款一句。机械对账门 = `test/server-keyset-parity.test.ts`。
10306
+ requiresRealApproval:
10307
+ type: boolean
10308
+ enum: [true]
10309
+ description: >
10310
+ server >= 7.30.0 (core 5.37, #283), "tool_approval" only, ADDITIVE: the SAFETY-class provenance
10311
+ bit core stamps at the gate. Present (true) or ABSENT — never encoded as false. Deliberately a
10312
+ DIFFERENT contract from the card's `risk.requiresRealApproval` (which is an always-present
10313
+ boolean): the card form needs the durable ask store, and this key is what lets a LIVE-ONLY
10314
+ deployment read the bit at all. Present ⇒ every automatic allowance (remembered rules,
10315
+ bypassPermissions posture) stands down.
10316
+ origin: { $ref: '#/components/schemas/AskOrigin' }
10317
+ inputHasBidi:
10318
+ type: boolean
10319
+ enum: [true]
10320
+ description: >
10321
+ server >= 7.45.0 (E-14), "tool_approval" only, ADDITIVE: the TO-BE-EXECUTED input (`args`)
10322
+ contains Unicode bidi control characters — what the eye reads may not be the byte order that
10323
+ runs. DISCLOSURE bit, bytes unchanged (sanitising would change the bytes about to execute, which
10324
+ is WORSE than not disclosing); rendering is the shell's. Present (true) or ABSENT; absence !=
10325
+ "verified clean". Computed BEFORE the byte cap, so it can ride a frame whose `args` were omitted.
10326
+ expiresAtMs:
10327
+ type: integer
10328
+ description: >
10329
+ server >= 7.34.0 (#288), "tool_approval" only, ADDITIVE: window triple (with `expiresInMs` and
10330
+ `serverNowMs`, same names/semantics as on ApprovalRequestFrame) so the LEGACY frame family can
10331
+ render a countdown too. `expiresAtMs` = this ask's absolute deadline on the server clock. The
10332
+ three keys are BORN AND ABSENT TOGETHER — any frame that gets emitted carries all three.
10333
+ expiresInMs:
10334
+ type: integer
10335
+ description: 'Window triple (see expiresAtMs): max(0, expiresAtMs - serverNowMs), computed at mint time.'
10336
+ serverNowMs:
10337
+ type: integer
10338
+ description: 'Window triple (see expiresAtMs): the server clock at FRAME MINT time — the countdown anchor; use it to correct local clock skew.'
10339
+ ruleOffersAbsence: { $ref: '#/components/schemas/RuleOffersAbsence' }
10340
+ denialLimitFallback: { $ref: '#/components/schemas/DenialLimitFallback' }
10341
+ parked:
10342
+ type: boolean
10343
+ enum: [true]
10344
+ description: >
10345
+ server >= 7.42.0 (#329), "tool_approval_complete" ONLY, ADDITIVE: the explicit discriminator for
10346
+ the PARK sense of `outcome: "expired"` (which is one word with three landings). Present ⇔ THIS ask
10347
+ settled through the park route ⇒ render "moved to the background queue", not "the card died".
10348
+ 🔴 ABSENCE MUST NOT BE READ AS "really denied": a store-less deployment, a deny policy, and a
10349
+ sibling VOIDed in a batch all emit no such key — absence only means "no park evidence".
10016
10350
  outcome:
10017
10351
  type: string
10018
10352
  enum: [allowed, denied, expired]
@@ -10123,6 +10457,15 @@ components:
10123
10457
  the PRESENCE-IS-THE-PROMISE clause (present only where a rule store is wired) and the redemption
10124
10458
  scope are stated verbatim on ToolApprovalFrame.ruleSuggestions — the live frame, the stored
10125
10459
  `card_json` and the replayed frame carry the SAME material.
10460
+ ruleOffers:
10461
+ type: array
10462
+ minItems: 1
10463
+ items: { $ref: '#/components/schemas/RuleOffer' }
10464
+ description: >
10465
+ server >= 7.46.0 (core 5.58.0, design/375) — the discriminated-union replacement of
10466
+ `ruleSuggestions` above. Same material as the live frame and the stored `card_json` (the server
10467
+ projects all three through one `copyRuleOffer`), so semantics, order-is-contract, presence-is-the-
10468
+ promise and the per-leg index rules are stated verbatim on ToolApprovalFrame.ruleOffers.
10126
10469
  persistedRuleShadowed:
10127
10470
  type: string
10128
10471
  description: >
@@ -10141,6 +10484,10 @@ components:
10141
10484
  probeCause: { type: object, additionalProperties: true, description: 'server >= 7.17.0: structured reversibility-probe tightening cause — same shape and semantics as ToolApprovalFrame.probeCause ({code, roots:{shown,total}, further?:{shown,total}}); frame and card carry the SAME value (one narrow-read function server-side). See that key for the full contract.' }
10142
10485
  ruleEvidence: { type: object, additionalProperties: true, description: 'server >= 7.23.0: rule-provenance evidence — same shape and semantics as ToolApprovalFrame.ruleEvidence (each member a value or a NAMED absence word; display/reconciliation metadata, never adjudication input). Frame and card carry the SAME value. See that key for the full contract.' }
10143
10486
  delegation: { $ref: '#/components/schemas/ApprovalCardDelegation' }
10487
+ # S-139(sdk 8.3.0):同 A-057.④ 的理由 —— server 早随卡投出(帧与卡同一只窄读函数 ⇒ 同值),
10488
+ # 但本 schema 此前只在帧上公示过。长 description 的单一真源在 ToolApprovalFrame 的同名键。
10489
+ ruleOffersAbsence: { $ref: '#/components/schemas/RuleOffersAbsence' }
10490
+ denialLimitFallback: { $ref: '#/components/schemas/DenialLimitFallback' }
10144
10491
 
10145
10492
  RuleSuggestion:
10146
10493
  type: object
@@ -10163,13 +10510,332 @@ components:
10163
10510
  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.'
10164
10511
  command: { type: string, maxLength: 512, description: 'The command pattern inside the rule, for a shell that would rather not re-parse `rule`.' }
10165
10512
 
10513
+ # ─── S-139(sdk 8.3.0):对 server 7.60.0 整体重对账带进来的四张型面 ───────────────────────
10514
+ AskOrigin:
10515
+ type: string
10516
+ enum: [content_question, unresolvable, org_unavailable, org_rule, hook, ask_rule, denial_limit_fallback, policy]
10517
+ description: >
10518
+ WHO raised an ask (`ToolApprovalFrame.origin`; server >= 7.57.0 / core 7.5.0, S-125③/#564) — core's
10519
+ `ASK_ORIGINS`, ENGINE-STAMPED at the gate (core's words: "engine-stamped at the gate, never a policy's
10520
+ claim"), transcribed verbatim by the server, which deliberately mints no second eligibility table of
10521
+ its own.
10522
+
10523
+ 🔴 READ AS AN OPEN SET. The vocabulary's sole owner is core and it has grown before; branch with a
10524
+ default arm and treat an unknown word as "unknown origin", never as "no origin".
10525
+ 🔴 NOT interchangeable with `governanceForced`: this says which KIND of authority asked, that one says
10526
+ whether THIS DEPLOYMENT's ops governance layer is the gate — `origin: policy` covers any deployment
10527
+ ToolPolicy's ask, so a governance-produced ask and an ordinary one look identical in this one word.
10528
+
10529
+ RuleOffersAbsence:
10530
+ type: string
10531
+ enum: [mandated, shadowed, lane_cannot_speak]
10532
+ description: >
10533
+ WHY `ruleOffers` is absent while the rule lane IS present (`ToolApprovalFrame.ruleOffersAbsence` and
10534
+ `ApprovalCard.ruleOffersAbsence`; server >= 7.55.0 / core #490 fix ②, S-15). The engine names which
10535
+ door is shut: `mandated` = this ask must be confirmed every time (🔴 this arm must NOT point the user
10536
+ at writing a rule — a rule would not silence it, and saying so sends them into a loop); `shadowed` = a
10537
+ rule matched but could not clear it (the machine-readable twin of `persistedRuleShadowed`);
10538
+ `lane_cannot_speak` = the engine could mint no coverable form for this command.
10539
+
10540
+ Engine-side MUTUALLY EXCLUSIVE with `ruleOffers`. 🔴 ABSENCE IS NOT AN ASSERTION — it covers both
10541
+ "there are offers" and the three structural doors, and one frame cannot tell them apart. ADVISORY
10542
+ display metadata, NEVER adjudication input. The frame and the stored `card_json` go through the SAME
10543
+ server-side narrow read, so both faces carry identical values; a word outside the set is treated as
10544
+ malformed and the key is not minted at all (the server never passes an unknown word through).
10545
+
10546
+ DenialLimitKind:
10547
+ type: string
10548
+ enum: [consecutive, total]
10549
+ description: 'Which bound tripped in a `DenialLimitFallback` — the consecutive-refusal limit or the cumulative one.'
10550
+
10551
+ DenialLimitFallback:
10552
+ type: object
10553
+ additionalProperties: false
10554
+ required: [consecutive, total, limit, autoDenyAfterMs]
10555
+ description: >
10556
+ The auto-mode classifier's DENIAL-LIMIT FALLBACK card (`ToolApprovalFrame.denialLimitFallback` and
10557
+ `ApprovalCard.denialLimitFallback`; server >= 7.57.0 / core 7.4.0 #548, S-114): the call that hit the
10558
+ consecutive (default 3) or cumulative (default 20) refusal limit is no longer silently denied — it
10559
+ becomes a card a HUMAN must decide. The same mint also stamps `requiresRealApproval`; the two are twins.
10560
+
10561
+ 🔴 ALL FOUR MEMBERS ARE REQUIRED: core's shape has no optional member, so "one missing" is a bad value
10562
+ rather than an older core, and the server drops the whole key instead of half-minting a card whose
10563
+ counts would be misread.
10564
+ 🔴 `autoDenyAfterMs` (ms; `0` = not armed — the deployment turned the knob off, or a TOTAL-tier card
10565
+ waits for a person) is for RENDERING THE COUNTDOWN ONLY. Never start a second timer from it: the window
10566
+ is executed by the ENGINE, and two overlapping windows are worse than the original defect and silent.
10567
+ 🔴 ABSENCE IS NOT AN ASSERTION — the vast majority of asks are not fallback cards.
10568
+ ⚠️ The durable (parked) leg does NOT carry this key today; the card's copy rides `card_json`, which is
10569
+ a different leg from the parked row's `pendingAction`.
10570
+ properties:
10571
+ consecutive: { type: integer, description: 'Consecutive refusals at the moment the limit tripped.' }
10572
+ total: { type: integer, description: 'Cumulative refusals at the moment the limit tripped.' }
10573
+ limit: { $ref: '#/components/schemas/DenialLimitKind' }
10574
+ autoDenyAfterMs: { type: integer, description: 'This card''s own auto-deny window in ms; 0 = not armed. Countdown rendering only.' }
10575
+
10576
+ RuleOfferMatch:
10577
+ type: string
10578
+ description: >
10579
+ The MATCH FORM of a persisted rule — the server's `PERSISTED_RULE_MATCHES`, verbatim (the vocabulary
10580
+ is OWNED BY CORE; the server only transcribes it and pins set equality, so core adding a member turns
10581
+ the server red at compile time).
10582
+
10583
+ `exact` = this one command only (`Bash(git status)`). `prefix` = the historical colon-star
10584
+ word-boundary prefix (`Bash(git status:*)`) and the ONLY spelling for a compound prefix form; it stays
10585
+ a permanent compatibility read. `wildcard` = the trailing space-star form (`Bash(npm run *)`), the
10586
+ same predicate as `prefix` on a single command body, minted by core's suggestion face since
10587
+ design/382 / #510. `subpath` = the `Read(//abs-dir/**)` directory form, which never holds a command.
10588
+
10589
+ 🔴 FOUR MEMBERS, not "the three today's miner emits": this type describes what a consumer may RECEIVE,
10590
+ and the server's wire validator accepts all four. A consumer type narrower than the mint point is a
10591
+ lie — it lets a `switch` claim exhaustiveness the wire does not honour. (The retired `RuleSuggestion`
10592
+ declared only two, which is exactly why a legal `wildcard` candidate used to be dropped as malformed.)
10593
+ enum: [exact, prefix, wildcard, subpath]
10594
+
10595
+ RuleOfferUncoveredReason:
10596
+ type: string
10597
+ description: >
10598
+ Why one segment of a compound command is STILL not covered after redeeming the batch (design/382
10599
+ §3.5; the server's `UNCOVERED_SEGMENT_REASONS`, core-owned). `redirection` = a redirection is asked
10600
+ every time, permanently. `no_rule_form` = there is no rule form that could cover this segment.
10601
+ `cap_overflow` = deduplication plus the batch cap pushed it out.
10602
+ enum: [redirection, no_rule_form, cap_overflow]
10603
+
10604
+ RuleOfferUncoveredDetail:
10605
+ type: object
10606
+ description: >
10607
+ design/382 §3.5, ADDITIVE — one "why is this segment still uncovered" row. `segment` is the folded
10608
+ segment's ORIGINAL BYTES (command family: UNTRUSTED for display, and subject to the same redaction /
10609
+ truncation discipline as a member's `segment`).
10610
+ additionalProperties: false
10611
+ required: [segment, reason]
10612
+ properties:
10613
+ segment: { type: string, maxLength: 512 }
10614
+ reason: { $ref: '#/components/schemas/RuleOfferUncoveredReason' }
10615
+
10616
+ RuleOfferBatchMember:
10617
+ description: >
10618
+ ONE MEMBER of a `batch` offer — the rule folded out of one segment of a compound command
10619
+ (design/382 §2.3 B3; discriminated on `kind`, core-owned closed vocabulary).
10620
+
10621
+ `segment` records WHICH folded segment this member came from. Core states it explicitly as a RENDERING
10622
+ SEAT that never participates in adjudication, and it is UNTRUSTED for display exactly like
10623
+ `rule` / `command` / `directory`. ⚠️ Only `segment` may be TRUNCATED by the server (an honest
10624
+ elision); `rule` / `command` / `directory` sit on the left-hand side of the redemption equality and
10625
+ are NEVER truncated.
10626
+
10627
+ 🔴 AN UNRECOGNISED MEMBER `kind` DROPS THE WHOLE BATCH, never the single member (core's normative
10628
+ degradation clause): a batch missing one member renders "yes to N" as "yes to N-1", which is worse
10629
+ than not rendering it. Any `single` offers beside it are complete and honest, so they stay.
10630
+ oneOf:
10631
+ - type: object
10632
+ additionalProperties: false
10633
+ required: [kind, rule, match, command, segment]
10634
+ properties:
10635
+ kind: { type: string, enum: [command] }
10636
+ rule: { type: string, maxLength: 512 }
10637
+ match: { $ref: '#/components/schemas/RuleOfferMatch' }
10638
+ command: { type: string, maxLength: 512 }
10639
+ segment: { type: string, maxLength: 512 }
10640
+ - type: object
10641
+ additionalProperties: false
10642
+ required: [kind, rule, directory, segment]
10643
+ properties:
10644
+ kind: { type: string, enum: [directoryRead] }
10645
+ rule: { type: string, maxLength: 512, description: 'The `Read(//abs-dir/**)` directory-read grant minted from a `cd` segment.' }
10646
+ directory: { type: string, maxLength: 512, description: 'Lexically normalised absolute directory — a rendering seat, so the shell need not re-parse `rule`. This arm carries NO `match` / `command`.' }
10647
+ segment: { type: string, maxLength: 512 }
10648
+ discriminator:
10649
+ propertyName: kind
10650
+
10651
+ RuleOffer:
10652
+ description: >
10653
+ ONE "don't ask again" OPTION on an approval card (core 5.58.0 design/375 §3.1; server >= 7.46.0 emits
10654
+ the same shape on THREE legs — the live `tool_approval` frame, the stored `card_json`, and the durable
10655
+ park row). It is the BREAKING replacement of `RuleSuggestion`, with no alias.
10656
+
10657
+ 🔴 READ `kind`. ORDER IS CONTRACT: a whole-string exact `single` is index 0 when present, a `batch` is
10658
+ always last, core's cardinality is <= 2 and the server's enforcement cap is 4.
10659
+
10660
+ 🔴 TWO ARMS, TWO SELECTION KEYS (see PersistRuleSelection). The `single` arm is redeemed by echoing its
10661
+ `rule` text VERBATIM — the server locates it BY TEXT, so a hand-built string is refused
10662
+ (`rule_not_offered`) by construction. The `batch` arm has no single text to copy (it is ONE yes for
10663
+ every member, with no per-member ticking), so it is redeemed BY INDEX, and the server additionally
10664
+ checks an anti-drift equality before honouring it: the batch re-minted from the adjudicated command
10665
+ must have the SAME member text set as the one the person saw, or the redemption is refused and the way
10666
+ forward is to re-trigger the command for a fresh card.
10667
+
10668
+ 🔴 INDEX SEMANTICS DIFFER PER LEG — the live card leg's index is a legal selection key, the durable
10669
+ queue row's is not (that projection compacts). Both statements are on the two `ruleOffers` properties.
10670
+ oneOf:
10671
+ - type: object
10672
+ additionalProperties: false
10673
+ required: [kind, rule, match, command]
10674
+ properties:
10675
+ kind: { type: string, enum: [single] }
10676
+ rule: { type: string, maxLength: 512, description: 'The canonical rule text to echo back verbatim on redemption (server MAX_RULE_TEXT_CHARS = 512).' }
10677
+ match: { $ref: '#/components/schemas/RuleOfferMatch' }
10678
+ command: { type: string, maxLength: 512, description: 'The command pattern inside the rule, for a shell that would rather not re-parse `rule`.' }
10679
+ - type: object
10680
+ additionalProperties: false
10681
+ required: [kind, rules, uncoveredSegments]
10682
+ properties:
10683
+ kind: { type: string, enum: [batch] }
10684
+ rules:
10685
+ type: array
10686
+ minItems: 1
10687
+ items: { $ref: '#/components/schemas/RuleOfferBatchMember' }
10688
+ description: 'The per-segment members (segment order, deduplicated by rule text). Core contract 1..5; the server tolerates up to 8.'
10689
+ uncoveredSegments:
10690
+ type: integer
10691
+ minimum: 0
10692
+ description: >
10693
+ The HONEST SURPLUS at mint time: how many segments of this compound command would still not be
10694
+ allowed by any rule after redeeming this batch, under the coverage snapshot taken then. `0`
10695
+ means "redeem this and the whole command is covered, as of that snapshot" — a statement about
10696
+ the snapshot, not a lasting guarantee (a concurrent rule revocation makes it stale).
10697
+ uncoveredDetail:
10698
+ type: array
10699
+ items: { $ref: '#/components/schemas/RuleOfferUncoveredDetail' }
10700
+ description: >
10701
+ design/382 §3.5, ADDITIVE — the per-segment reason behind `uncoveredSegments`. When present the
10702
+ row count equals `uncoveredSegments`, but THE COUNT IS THE SOURCE OF TRUTH: the server drops a
10703
+ malformed detail seat without dropping the batch, so an absent `uncoveredDetail` is NOT an
10704
+ assertion that nothing is uncovered.
10705
+ discriminator:
10706
+ propertyName: kind
10707
+
10708
+ PersistedRuleAnchor:
10709
+ type: object
10710
+ description: >
10711
+ server >= 7.48.0 (P-39), ADDITIVE — one self-describing attribution anchor for
10712
+ `ToolApprovalRespondAck.persistedRules`, index-aligned with it.
10713
+
10714
+ The disease it closes: per-entry attribution of `persistedRules` used to be inferable only from
10715
+ POSITION, and position was not provable on the wire (the ack order came from the server's RE-MINTED
10716
+ batch walk, matching the documented display order only by coincidence — the anti-drift check is a SET
10717
+ equality, order is not in it).
10718
+
10719
+ 🔴 BOTH INDEXES LIVE IN THE OFFER SPACE, so a consumer needs no mapping: `offerIndex` echoes the
10720
+ request's `persistRule.batchOfferIndex` VERBATIM (correlation: "this ack really is for the redemption
10721
+ I sent"), and `memberIndex` is the position inside that batch's `rules[]` on the frame. Core's
10722
+ candidate space is deliberately kept off the wire.
10723
+ additionalProperties: false
10724
+ required: [offerIndex, memberIndex, rule]
10725
+ properties:
10726
+ offerIndex: { type: integer, minimum: 0 }
10727
+ memberIndex: { type: integer, minimum: 0 }
10728
+ rule: { type: string, maxLength: 512, description: 'The canonical text this member actually landed as (equal to the same-index entry of `persistedRules`).' }
10729
+
10730
+ PersistRuleSelection:
10731
+ description: >
10732
+ The body field `persistRule` of POST /v1/tool-approvals/{id}/respond — TWO MUTUALLY EXCLUSIVE ARMS,
10733
+ matching the server's `ParsedPersistRule` one for one.
10734
+
10735
+ 🔴 THERE IS NO `kind` ON THE WIRE: that is the server's own post-parse discriminator. The arms are told
10736
+ apart by WHICH FIELD IS PRESENT.
10737
+
10738
+ 🔴 MUTUAL EXCLUSION IS A LOUD 400, not "pick one silently" (server constant
10739
+ PERSIST_RULE_BATCH_EXCLUSIVE_ERROR, verbatim): "persistRule.batchOfferIndex is mutually exclusive with
10740
+ persistRule.rule / persistRule.edited — send exactly one arm". Two arms are two DIFFERENT
10741
+ authorisations; guessing between them is deciding for the person.
10742
+
10743
+ 🔴 MALFORMED IS ALSO A 400, never folded (PERSIST_RULE_BATCH_INDEX_ERROR, verbatim):
10744
+ "persistRule.batchOfferIndex must be a non-negative integer when present" — a negative, fractional or
10745
+ non-numeric index that got coerced points at ANOTHER offer. Likewise
10746
+ PERSIST_RULE_EDITED_FLAG_ERROR: "persistRule.edited must be a boolean when present" — a non-boolean is
10747
+ NEVER silently treated as false, because that would quietly fold a "I want to store free text" back
10748
+ into the candidate arm and answer `rule_not_offered`, leaving the person to think their own rule was
10749
+ rejected. And `rule` must be a non-empty string of at most 512 characters.
10750
+
10751
+ 🔴 NO SCOPE IN THE BODY: a top-level `scope` and a `persistRule.scope` are both loud 400s
10752
+ ("this endpoint does not accept a rule scope — the server mints it from where the approval happened").
10753
+ Letting the caller name a scope is letting a consent given inside project A be written as a standing
10754
+ allow over project B (or globally) — the most expensive widening available on this axis.
10755
+
10756
+ ⚠️ ON `decision: "deny"` THE WHOLE FIELD IS NOT PARSED AT ALL (tolerated, ignored, no 400 — same
10757
+ posture as `updatedInput`): having refused the operation, consenting to it persistently is meaningless.
10758
+ So the refusals above apply to the allow family; only the TOP-LEVEL `scope` 400 is decision-independent.
10759
+ oneOf:
10760
+ - type: object
10761
+ title: candidate-or-edited-arm
10762
+ required: [rule]
10763
+ # 🔴 互斥必须 EXPLICIT(codex R2-F1,验真后修):两臂都是开集,所以每一臂都能把对面臂的键
10764
+ # 当「多余键」收下 —— `oneOf` 数的是「几臂命中」,于是 `{rule, batchOfferIndex:-1}` 会因为
10765
+ # 批臂被 `minimum:0` 判假而**恰好命中一臂**,schema 放行、server 报互斥 400。靠「另一臂恰好
10766
+ # 判假」表达互斥是间接后果,不是约束。
10767
+ not: { required: [batchOfferIndex] }
10768
+ properties:
10769
+ rule:
10770
+ type: string
10771
+ minLength: 1
10772
+ maxLength: 512
10773
+ description: >
10774
+ With `edited` absent or false this is the CANDIDATE ARM: echo back, VERBATIM, the `rule` of the
10775
+ chosen `kind: "single"` entry of the frame's `ruleOffers` (or, against a <= 7.45 server, of
10776
+ `ruleSuggestions`). Do not hand-build it, re-case it or append `:*` — the server locates it by
10777
+ text and refuses otherwise (`rule_not_offered`). With `edited: true` this is the FREE-TEXT ARM
10778
+ and the string is whatever the person typed.
10779
+ edited:
10780
+ type: boolean
10781
+ description: >
10782
+ server >= 7.44.0 (#340), ADDITIVE — capability bit `respondFreeFormRules`, PROBE IT FIRST.
10783
+ `true` says the text above was hand-edited on the card, so the candidate-table equality is not
10784
+ applied; the engine judges it instead through three gates (the shared validator, a COVERAGE
10785
+ gate requiring the edited rule to still hold the command being adjudicated, and scope
10786
+ inherited from this card). On success the ack carries `persistedRule` = the canonical spelling
10787
+ that really landed. An older worker drops the unknown key and judges the text AS A CANDIDATE,
10788
+ answering `rule_not_offered` — honest, but easily misread as "I wrote it wrong".
10789
+
10790
+ ⚠️ Together with `updatedInput` the persistence is refused (`rule_input_edited`): the call that
10791
+ takes effect is the edited one while the candidates came from the ORIGINAL command. The
10792
+ decision itself still stands.
10793
+ # 🔴 刻意**不封闭**(codex R1-F2,验真后修;main 的同一格早有同款注释,换形时别把它丢了):
10794
+ # server 的 `parseToolApprovalResponse` 是手写 parser,只读它认识的键,对象里多给的键**忽略**
10795
+ # (与 `updatedInput` 同一条宽收姿势)。写 `additionalProperties:false` 会让照 spec 生成的客户端
10796
+ # 拒发一个 server 其实会受理的请求 —— 例如一个把整只候选对象 `{rule, match, command}` 原样转发的
10797
+ # 存量壳,重新 codegen 之后就发不出去了。那是我们自己造的漂移,不是上游的约束。
10798
+ # 互斥**不靠封闭表达**:`oneOf` 的语义是「恰好命中一臂」,两臂各自 `required` 不同 ⇒
10799
+ # `{rule, batchOfferIndex}` 同时命中两臂 ⇒ 判假。开集与互斥两件事各自成立。
10800
+ - type: object
10801
+ title: batch-arm
10802
+ required: [batchOfferIndex]
10803
+ # 同上一臂:`{batchOfferIndex:0, edited:false}` 曾因为文本臂缺 `rule` 判假而恰好命中本臂。
10804
+ not: { anyOf: [{ required: [rule] }, { required: [edited] }] }
10805
+ properties:
10806
+ batchOfferIndex:
10807
+ type: integer
10808
+ minimum: 0
10809
+ description: >
10810
+ server >= 7.46.0 (design/377), ADDITIVE — capability bit `respondBatchRuleOffers`, PROBE IT
10811
+ FIRST. The person ticked a `kind: "batch"` offer; the value is that offer's INDEX in the
10812
+ frame's `ruleOffers`. A conjunctive batch has no single text to copy, so the index is the only
10813
+ possible selection key.
10814
+
10815
+ 🔴 NARROWLY OPENED TO BATCH ONLY: an index that points nowhere, or points at a `single`, yields
10816
+ `rulePersisted:false` + `rule_not_offered` — what was never rendered can never be redeemed, and
10817
+ the `single` arm's anti-forgery anchor is its TEXT equality, which an index may not bypass.
10818
+ 🔴 An older worker drops the unknown key and then fails the `rule` shape check with a 400
10819
+ — honest, but it cannot tell that a batch was ticked. If the capability bit is absent, do not
10820
+ render a redeemable batch affordance.
10821
+ # 同上一臂:刻意不封闭(理由逐字见那一段)。
10822
+
10166
10823
  RuleRefusalReason:
10167
10824
  type: string
10168
10825
  description: >
10169
10826
  Why a "don't ask again" did NOT get persisted (#154 车二; the server's `RuleRefusalReason`, verbatim).
10170
10827
  A CLOSED set so a shell can branch instead of matching prose. The two naming styles are NOT a typo:
10171
- the four hyphenated members are derived from the consent lane's own refusal reasons, the four
10172
- underscored ones are minted by the server's gates.
10828
+ the SEVEN hyphenated members are derived from the consent lane's own refusal reasons
10829
+ (server `CardRulePersisted`), the SIX underscored ones are minted by the server's gates.
10830
+
10831
+ `edit-unsupported` and `edit-rejected` (server >=7.44.0, #340) belong to the FREE-TEXT arm and ask the
10832
+ shell for OPPOSITE things: the first means the edit affordance does not work on this card (no binding
10833
+ anchor, or the deployment has it off) so STOP RENDERING THE INPUT BOX; the second means the engine's
10834
+ gates refused this particular text, so "fix it and try again" — and since #345 (server >=7.51.0) the
10835
+ independently decidable half of that judgement happens BEFORE the decision settles (a 400 with the card
10836
+ still alive), so trying again really works. `scope-unresolved` (core 7.0.0 #490②) is the death of the
10837
+ silent global: with neither an explicit scope nor a cwd the lane refuses loudly instead of defaulting to
10838
+ global, which makes it a ROUTINE answer on a deployment that has not wired `ruleScopeRootFor`.
10173
10839
 
10174
10840
  🔴 A refusal NEVER flips the decision. The person's "allow this once" has already taken effect and
10175
10841
  reached the engine; turning the whole respond into a 4xx would make a real allow vanish. The honest
@@ -10200,16 +10866,30 @@ components:
10200
10866
  note on `rule_lane_unavailable` above already says never to disable the feature globally on it, and
10201
10867
  the same holds here. The one correct source for "does this worker serve rules at all" is
10202
10868
  `capabilities.permissionRules`. Keep offering "don't ask again" on the next ask; just not on this one.
10869
+ `rule_material_absent` (server >=7.48.0, #363) — DURABLE (PARKED) LATE-DECISION LEG ONLY: THIS ROW
10870
+ carries no material to mint a rule from. Three causes, one handling: the ask never entered the rule
10871
+ lane (governance-forced / no owner / the engine minted no candidate / the command bytes are not
10872
+ readable out of the args), the row predates those columns, or the card on the row will not parse.
10873
+ 🔴 ALSO SPLIT FROM `rule_lane_unavailable` ON PURPOSE, for the same reason as the previous member:
10874
+ that word says "this deployment serves no rules at all" and a shell may stop rendering the affordance
10875
+ on it, whereas the truth here is "the lane is fine, only THIS ROW has no material". Folding them lets
10876
+ one late park answer condemn a healthy deployment's whole lane. Silently ignoring it is worse still
10877
+ (no silent fail-open on the approval axis): a person who ticked "don't ask again" and saw nothing
10878
+ happen will not tick it twice.
10203
10879
  enum:
10204
10880
  - no-candidates
10205
10881
  - unknown-candidate
10206
10882
  - confirm-refused
10207
10883
  - redeem-refused
10884
+ - edit-unsupported
10885
+ - edit-rejected
10886
+ - scope-unresolved
10208
10887
  - rule_lane_unavailable
10209
10888
  - rule_input_edited
10210
10889
  - rule_not_offered
10211
10890
  - rule_store_error
10212
10891
  - rule_governance_forced
10892
+ - rule_material_absent
10213
10893
 
10214
10894
  ApprovalRiskAxes:
10215
10895
  type: object
@@ -11864,6 +12544,44 @@ components:
11864
12544
  next ask" (`rule_input_edited`) from "there was nothing redeemable on this card"
11865
12545
  (`rule_lane_unavailable`). 🔴 `rule_lane_unavailable` covers four causes, two of them per-card — do
11866
12546
  NOT read it as "this deployment has no rule lane" (see the RuleRefusalReason schema).
12547
+ persistedRule:
12548
+ type: string
12549
+ maxLength: 512
12550
+ description: >
12551
+ server >= 7.44.0 (#340), ADDITIVE — FREE-TEXT ARM ONLY: the CANONICAL SPELLING of the rule that
12552
+ actually landed (core may normalise `Bash(adb *)` into `Bash(adb:*)`). Echo THIS back to the
12553
+ person, not the bytes they typed into the box.
12554
+
12555
+ 🔴 ABSENCE HAS THREE MEANINGS, none of them "the rule is gone": (1) this respond used the
12556
+ CANDIDATE arm — the server's `editedArmEcho` is the single mint point for this key, so a candidate
12557
+ respond's ack is byte-for-byte unchanged; (2) nothing landed (read `rulePersisted` / `ruleRefusal`);
12558
+ (3) the respond carried no `persistRule` at all.
12559
+ persistedRules:
12560
+ type: array
12561
+ items: { type: string, maxLength: 512 }
12562
+ description: >
12563
+ server >= 7.46.0 (design/377), ADDITIVE — BATCH ARM ONLY: the canonical text of EVERY member that
12564
+ landed in this one redemption. A conjunctive batch is one yes for the whole set; if any member is
12565
+ refused the ack is `rulePersisted:false` + `ruleRefusal:"redeem-refused"` and the members that DID
12566
+ land are not rolled back (their dots are on record and a re-triggered card dedupes idempotently).
12567
+
12568
+ 🔴 ORDER: from server 7.48.0 on, the DISPLAY order of the requested offer
12569
+ (`ruleOffers[batchOfferIndex].rules`) is a PROMISE, not a coincidence; on 7.46-7.47 it inherited the
12570
+ server's re-minted walk order instead. For per-entry attribution read `persistedRuleAnchors`
12571
+ rather than inferring it from position.
12572
+ 🔴 Not the same thing as `persistedRule`: the singular key belongs to the single/edited arm, the
12573
+ plural one to the batch arm, and the two never appear together.
12574
+ persistedRuleAnchors:
12575
+ type: array
12576
+ items: { $ref: '#/components/schemas/PersistedRuleAnchor' }
12577
+ description: >
12578
+ server >= 7.48.0 (P-39), ADDITIVE and with NO capability bit — the per-entry, self-describing
12579
+ attribution anchors for `persistedRules`, INDEX-ALIGNED with it.
12580
+
12581
+ Minted ONLY on the batch arm (the single/edited arm has no member coordinates to speak of), and
12582
+ omitted WHOLESALE whenever the server cannot prove attribution — an honest absence, never a
12583
+ fabricated index. A consumer that sees it absent knows this ack can only be read positionally, and
12584
+ `persistedRules` itself is unchanged to the byte (older consumers ignore the unknown key).
11867
12585
  noteRecorded:
11868
12586
  type: boolean
11869
12587
  description: >