|
| 1 | +# Signet -> draft-02 adapter sketch |
| 2 | + |
| 3 | +Pseudocode for a thin shim that translates a Signet receipt (as emitted by `Prismer-AI/signet@main` at the date of this PR) into a draft-farley-acta-signed-receipts-02 envelope verifiable against `@veritasacta/verify`. |
| 4 | + |
| 5 | +**Status:** Sketch. The Signet maintainer has confirmed the co-signer shape below and committed to shipping this as a `--emit-draft02` flag on `signet sign` (main repo), plus an in-workspace `signet-draft02-adapter` crate. Production implementation is scheduled on the Signet side; this file remains as the architectural reference. Open questions at the bottom are resolved. |
| 6 | + |
| 7 | +## Input |
| 8 | + |
| 9 | +A signed Signet receipt, as emitted by `signet_core::sign::sign()`. |
| 10 | + |
| 11 | +## Output |
| 12 | + |
| 13 | +Either: |
| 14 | + |
| 15 | +- **A draft-02 envelope that verifies against `@veritasacta/verify --key operator.pem`**, IF the adapter has access to the Signet operator's pubkey and can publish a JWKS at the correct discovery endpoint; OR |
| 16 | +- **A draft-02 envelope that fails verification with a deterministic `invalid_signature` error** — because the signature was generated over Signet's canonical form, not draft-02's. To produce a verifying draft-02 envelope, the adapter must re-sign. |
| 17 | + |
| 18 | +The latter is usually what is wanted: the adapter acts as a **co-signer** at the governance gateway, producing a draft-02 envelope in parallel with the Signet receipt. Both receipts are emitted; downstream consumers choose which to verify. |
| 19 | + |
| 20 | +## Pseudocode (co-signer mode) |
| 21 | + |
| 22 | +```python |
| 23 | +def signet_to_draft02(signet_receipt: dict, operator_signer: Ed25519Signer) -> dict: |
| 24 | + """Co-sign a Signet receipt as a draft-02 envelope. |
| 25 | +
|
| 26 | + signet_receipt: the decoded JSON of a Signet receipt. |
| 27 | + operator_signer: the same Ed25519 key that signed the Signet receipt, |
| 28 | + wrapped for draft-02-style JCS canonicalization. |
| 29 | +
|
| 30 | + Returns: a draft-02 envelope. |
| 31 | + """ |
| 32 | + action = signet_receipt["action"] |
| 33 | + |
| 34 | + payload = { |
| 35 | + "type": "signet:decision", |
| 36 | + "action": action["tool"], |
| 37 | + "agent_id": signet_receipt["signer"].get("name", "unknown"), |
| 38 | + "issuer_id": signet_receipt["signer"].get("owner", "unknown"), |
| 39 | + "issued_at": signet_receipt["ts"], |
| 40 | + "decision": "allow", # Or pull from signet's decision field if present |
| 41 | + } |
| 42 | + |
| 43 | + # Optional mappings (only if present in Signet) |
| 44 | + if "params_hash" in action: |
| 45 | + payload["action_ref"] = action["params_hash"] |
| 46 | + if "trace_id" in action: |
| 47 | + payload["iteration_id"] = action["trace_id"] |
| 48 | + if "parent_receipt_id" in action: |
| 49 | + # Requires the prior receipt (or its hash) to be resolvable. |
| 50 | + # See DEVIATIONS.md section 3. |
| 51 | + payload["previousReceiptHash"] = resolve_prior_hash(action["parent_receipt_id"]) |
| 52 | + |
| 53 | + # Map PolicyAttestation -> policy_digest / policy_id / decision. |
| 54 | + # Signet's PolicyAttestation struct uses policy_name and policy_hash. |
| 55 | + if signet_receipt.get("policy"): |
| 56 | + policy = signet_receipt["policy"] |
| 57 | + payload["policy_id"] = policy.get("policy_name") |
| 58 | + payload["policy_digest"] = policy.get("policy_hash") |
| 59 | + if policy.get("decision"): |
| 60 | + payload["decision"] = policy["decision"] |
| 61 | + # Suggested: attestation_evidence extension for richer fields |
| 62 | + if policy.get("matched_rules") or policy.get("reason"): |
| 63 | + payload["attestation_evidence"] = { |
| 64 | + "matched_rules": policy.get("matched_rules", []), |
| 65 | + "reason": policy.get("reason"), |
| 66 | + } |
| 67 | + |
| 68 | + # Map Authorization -> holder_binding (AIP-0003). |
| 69 | + # Signet's Authorization struct: { chain, chain_hash, root_pubkey }. |
| 70 | + # Only chain_hash and root_pubkey enter the signed scope; full chain |
| 71 | + # is carried for storage and dropped at adapt time. |
| 72 | + if signet_receipt.get("authorization"): |
| 73 | + auth = signet_receipt["authorization"] |
| 74 | + payload["holder_binding"] = { |
| 75 | + "mode": "jwk_thumbprint", |
| 76 | + "thumbprint": jwk_thumbprint_of_ed25519_pubkey(auth["root_pubkey"]), |
| 77 | + "delegation_chain_hash": auth["chain_hash"], |
| 78 | + } |
| 79 | + |
| 80 | + # Re-sign under draft-02 canonicalization (JCS RFC 8785, AIP-0001 ASCII-keys) |
| 81 | + canonical = jcs_canonicalize(payload) |
| 82 | + signature_bytes = operator_signer.sign(canonical) |
| 83 | + |
| 84 | + return { |
| 85 | + "payload": payload, |
| 86 | + "signature": { |
| 87 | + "alg": "EdDSA", |
| 88 | + "kid": jwk_thumbprint(operator_signer.public_key()), |
| 89 | + "sig": base64url_encode(signature_bytes).rstrip("="), |
| 90 | + }, |
| 91 | + } |
| 92 | +``` |
| 93 | + |
| 94 | +## Verification after adapt |
| 95 | + |
| 96 | +```bash |
| 97 | +# Write adapted envelope |
| 98 | +echo "$DRAFT02_ENVELOPE" > /tmp/signet-as-draft02.json |
| 99 | + |
| 100 | +# Publish operator pubkey as JWKS at well-known URL, then: |
| 101 | +npx @veritasacta/verify /tmp/signet-as-draft02.json --jwks https://operator.example/jwks |
| 102 | + |
| 103 | +# Or pin the key locally: |
| 104 | +npx @veritasacta/verify /tmp/signet-as-draft02.json --key ./operator-public.pem |
| 105 | +``` |
| 106 | + |
| 107 | +Expected output on a successful adapt + verify: |
| 108 | + |
| 109 | +``` |
| 110 | +✓ Signature valid |
| 111 | +✓ Issuer: signet:operator:... |
| 112 | +✓ Decision: allow |
| 113 | +✓ Issued: 2026-04-19T... |
| 114 | +✓ Tier: T1 (basic) |
| 115 | +``` |
| 116 | + |
| 117 | +## Alternative: transcode mode (no re-sign) |
| 118 | + |
| 119 | +If the operator specifically wants the SAME Ed25519 signature to verify under BOTH Signet and draft-02 rules, the transcoding is stricter: the canonicalization MUST produce the same byte string under both systems, which in practice requires the Signet canonical form and the draft-02 payload canonical form to be byte-identical. |
| 120 | + |
| 121 | +That is achievable but constrains Signet's field set (cannot include fields that draft-02 does not canonicalize, and vice versa). Given the known deltas on `params` (redacted in draft-02), this mode is not straightforward. |
| 122 | + |
| 123 | +**Co-sign mode is the recommended default.** Transcode mode is a design conversation for Path 3 (native alignment), not for an adapter. |
| 124 | + |
| 125 | +## Signet maintainer answers |
| 126 | + |
| 127 | +Resolved in the review of [VeritasActa/agt-integration-profile#1](https://github.com/VeritasActa/agt-integration-profile/pull/1): |
| 128 | + |
| 129 | +1. **Feature flag.** The co-signer emits as a `--emit-draft02` flag on `signet sign`. When set, both the Signet-native envelope and the draft-02 envelope are produced per operation. Lives in the main Signet repo (discoverable, tested in the main CI). |
| 130 | + |
| 131 | +2. **In-workspace adapter crate.** A `signet-draft02-adapter` crate is welcome inside the Signet Cargo workspace. Timing depends on the conformance bar; maintainer will prioritise after field mapping is confirmed (confirmed by this PR). |
| 132 | + |
| 133 | +3. **Co-signer mode is the right default.** The operator private key is accessible at the adapter site, so the adapter can produce a draft-02 envelope that verifies under the operator's public key. No cross-custody complications at the adapter site. |
| 134 | + |
| 135 | +The adapter therefore lives in the Signet main repo (not this repo, not third-party). This repo retains the architectural reference (this file) and the normative field mapping ([`FIELD-MAPPING.md`](./FIELD-MAPPING.md)). |
0 commit comments