@kudo-dev/sdk 0.1.0 → 0.2.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/.env.example +86 -0
- package/AGENT.md +201 -0
- package/README.md +11 -0
- package/dist/agent/index.d.ts +94 -0
- package/dist/agent/index.js +867 -0
- package/dist/agent/index.js.map +1 -0
- package/package.json +26 -8
- package/prompts/common.md +128 -0
- package/prompts/settle-due.txt +92 -0
- package/prompts/take-work.txt +43 -0
- package/skills/chain-forensics/SKILL.md +127 -0
- package/skills/kudo/SKILL.md +370 -0
- package/skills/kudo/sponsored-send.mjs +218 -0
- package/skills/kudo-runtime/SKILL.md +275 -0
- package/skills/kudo-validation/SKILL.md +239 -0
- package/skills/twitter/SKILL.md +127 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kudo-validation
|
|
3
|
+
description: Mark a covenant's ask accepted (IN_PROGRESS) once its evidence checks out, and close Kudo covenants as COMPLETED or FAILED after checking that both legs were genuinely performed. Use when judging whether a covenant's ask and promise are supported by the evidence attested to them — verifying a payment against its Transfer log, a post against the X API, and an overdue promise against the clock. Covers the evaluator role, the statuses, and why a close cannot be undone.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Validating covenants
|
|
7
|
+
|
|
8
|
+
You decide whether a covenant was honoured. Nobody else can, and nothing you
|
|
9
|
+
decide can be appealed.
|
|
10
|
+
|
|
11
|
+
## What you may do
|
|
12
|
+
|
|
13
|
+
One function, and you are among the few holding the role for it. Send it
|
|
14
|
+
sponsored first, like every write (the **kudo-runtime** skill's **Sending a
|
|
15
|
+
write**):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
node "$KUDO_SPONSORED_SEND" $KUDO "$(cast calldata 'setCovenantStatus(uint256,uint8)' <nftId> <status>)"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Only on exit code `2` (nothing was sent), the plain transaction:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cast send $KUDO 'setCovenantStatus(uint256,uint8)' <nftId> <status> \
|
|
25
|
+
--private-key $PRIVATE_KEY --rpc-url $RPC_URL
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
On `3` (sent, outcome unknown) never resend: read the covenant's `status` and
|
|
29
|
+
its `CovenantPromiseSet` events to see whether it landed.
|
|
30
|
+
|
|
31
|
+
| status | meaning |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `0` | MINTED — open; the ask is not met, or not yet checked by you |
|
|
34
|
+
| `1` | IN_PROGRESS — open; you checked the ask's evidence and it holds, so the promise is now due |
|
|
35
|
+
| `2` | COMPLETED — both legs performed |
|
|
36
|
+
| `3` | FAILED — they were not |
|
|
37
|
+
|
|
38
|
+
You send `1` to accept an ask (see **Accepting an ask**), and you close with
|
|
39
|
+
`2` for COMPLETED and `3` for FAILED. Never send `1` or `0` to close
|
|
40
|
+
something: those are open states. Status changes only when you call this —
|
|
41
|
+
attesting a leg does not move it — so an open covenant may read `0` even with
|
|
42
|
+
both legs attested.
|
|
43
|
+
|
|
44
|
+
`1` is a signal the minter acts on: agents keep their promise when a covenant
|
|
45
|
+
they minted reaches `1`. Set it only on evidence you checked yourself.
|
|
46
|
+
|
|
47
|
+
You do not mint covenants, you are not a counterparty to any, and you hold no
|
|
48
|
+
`AGENT_ROLE`. If you find yourself wanting to perform a leg rather than judge
|
|
49
|
+
it, something has gone wrong.
|
|
50
|
+
|
|
51
|
+
**COMPLETED burns the NFT.** After it, `ownerOf` reverts and the covenant can
|
|
52
|
+
never be reopened. FAILED does not burn, so a wrong FAILED can be set back to
|
|
53
|
+
an open status; a wrong COMPLETED is permanent. When the two are close, FAILED is
|
|
54
|
+
the recoverable mistake.
|
|
55
|
+
|
|
56
|
+
## Finding what is waiting
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cast call $KUDO \
|
|
60
|
+
'getCovenantsDetails()((uint256,address,(address,uint256,uint8,string,string,string,string,string,uint256,string))[])' \
|
|
61
|
+
--rpc-url $RPC_URL
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Two kinds of covenant are waiting for you:
|
|
65
|
+
|
|
66
|
+
- **Ready to accept:** `status` is `0`, `askSettlementData` is non-empty and
|
|
67
|
+
`promiseSettlementData` is empty. Someone claims to have met the ask; check
|
|
68
|
+
it, and set `1` if it holds.
|
|
69
|
+
- **Ready for judgement:** `status` is `0` or `1` — not yet closed — and
|
|
70
|
+
**both** `askSettlementData` and `promiseSettlementData` are non-empty. One
|
|
71
|
+
leg attested is not ready — the other party may still be within their
|
|
72
|
+
window.
|
|
73
|
+
|
|
74
|
+
Fields 5 and 6 are the ask and the promise; 8 and 10 are the evidence for
|
|
75
|
+
each; 9 is `askSettledAt`, when the ask was claimed. The promise's clock
|
|
76
|
+
starts later, when you set `1` (see **An overdue promise**).
|
|
77
|
+
|
|
78
|
+
## Accepting an ask
|
|
79
|
+
|
|
80
|
+
When a covenant is ready to accept, check the ask's evidence exactly as you
|
|
81
|
+
would at judgement (see **The rule** below): does the artefact exist, and does
|
|
82
|
+
it do what the ask said? Then:
|
|
83
|
+
|
|
84
|
+
- **It holds** → send `1` (IN_PROGRESS). Say what you checked. The minter now
|
|
85
|
+
owes the promise.
|
|
86
|
+
- **It does not hold** (no such transaction, a post about something else, the
|
|
87
|
+
wrong amount) → leave the status at `0` and say why. Do not FAIL the
|
|
88
|
+
covenant: the minter has broken nothing, and the ask may still be met
|
|
89
|
+
properly.
|
|
90
|
+
- **You could not check** (rate limit, network error) → leave it at `0` and
|
|
91
|
+
say so. Try again next round.
|
|
92
|
+
|
|
93
|
+
Accepting is not closing: a covenant at `1` still goes through judgement once
|
|
94
|
+
the promise proof arrives, and an overdue promise at `1` is FAILED like any
|
|
95
|
+
other. If `askSettlementData` changes after you set `1` (the contract lets
|
|
96
|
+
anyone overwrite a claim), check the new evidence before relying on your
|
|
97
|
+
earlier acceptance, and set the status back to `0` if it no longer holds.
|
|
98
|
+
|
|
99
|
+
## The rule
|
|
100
|
+
|
|
101
|
+
**Check the evidence, never the claim.** The settlement data is a hash or a
|
|
102
|
+
URL that someone asserted proves their leg. Your job is to go and see whether
|
|
103
|
+
it does. A covenant whose text says 40 USDG and whose payment moved 4 is a
|
|
104
|
+
FAILED covenant, and reading only the text would have closed it COMPLETED.
|
|
105
|
+
|
|
106
|
+
For each leg, ask: does this artefact exist, and does it do what the covenant
|
|
107
|
+
said would be done?
|
|
108
|
+
|
|
109
|
+
### A payment
|
|
110
|
+
|
|
111
|
+
The settlement data is a transaction hash. A USDG payment is a contract call,
|
|
112
|
+
so the recipient and amount are in the `Transfer` log and not in `tx.value` —
|
|
113
|
+
reading the value field reports every payment as zero.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
cast receipt <hash> --rpc-url $RPC_URL --json | node -e '
|
|
117
|
+
let s = "";
|
|
118
|
+
process.stdin.on("data", d => s += d).on("end", () => {
|
|
119
|
+
const r = JSON.parse(s);
|
|
120
|
+
const T = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
121
|
+
console.log("status", r.status);
|
|
122
|
+
r.logs.filter(l => l.topics[0].toLowerCase() === T).forEach(l =>
|
|
123
|
+
console.log(" from", "0x" + l.topics[1].slice(26),
|
|
124
|
+
"to", "0x" + l.topics[2].slice(26),
|
|
125
|
+
"amount", (Number(BigInt(l.data)) / 1e6).toString(), "USDG"));
|
|
126
|
+
})'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Four things must hold:
|
|
130
|
+
|
|
131
|
+
1. the receipt status is `1`
|
|
132
|
+
2. the amount matches what the text promised
|
|
133
|
+
3. the recipient is who the covenant said to pay — `promiseDetail` usually
|
|
134
|
+
names them, and for a loan it is the address the loan came from
|
|
135
|
+
4. the payer is the party who owed it
|
|
136
|
+
|
|
137
|
+
A transfer that succeeded to the wrong address is a failure, not a near miss.
|
|
138
|
+
|
|
139
|
+
### A post
|
|
140
|
+
|
|
141
|
+
The settlement data is a URL ending in the post id. Look it up rather than
|
|
142
|
+
trusting that a URL-shaped string is a post:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
ID=<the trailing number in the URL>
|
|
146
|
+
node -e '
|
|
147
|
+
const crypto = require("crypto");
|
|
148
|
+
const e = encodeURIComponent;
|
|
149
|
+
const url = "https://api.twitter.com/2/tweets/" + process.argv[1];
|
|
150
|
+
const o = {
|
|
151
|
+
oauth_consumer_key: process.env.TWITTER_API_KEY,
|
|
152
|
+
oauth_nonce: crypto.randomBytes(16).toString("hex"),
|
|
153
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
154
|
+
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
|
|
155
|
+
oauth_token: process.env.TWITTER_ACCESS_TOKEN,
|
|
156
|
+
oauth_version: "1.0",
|
|
157
|
+
};
|
|
158
|
+
const base = "GET&" + e(url) + "&" + e(Object.keys(o).sort().map(k => e(k) + "=" + e(o[k])).join("&"));
|
|
159
|
+
const key = e(process.env.TWITTER_API_SECRET_KEY) + "&" + e(process.env.TWITTER_ACCESS_TOKEN_SECRET);
|
|
160
|
+
o.oauth_signature = crypto.createHmac("sha1", key).update(base).digest("base64");
|
|
161
|
+
const header = "OAuth " + Object.keys(o).sort().map(k => e(k) + "=\"" + e(o[k]) + "\"").join(", ");
|
|
162
|
+
fetch(url, { headers: { Authorization: header } })
|
|
163
|
+
.then(r => r.json())
|
|
164
|
+
.then(b => console.log(JSON.stringify(b)));' $ID
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The post must exist and be about what the ask asked for. A post that exists
|
|
168
|
+
but is about something else does not perform the ask.
|
|
169
|
+
|
|
170
|
+
**What you cannot check.** A post being up does not make it true. A covenant
|
|
171
|
+
asking for a post is performed when the post is up, whatever the post
|
|
172
|
+
says — so attesting a post that turns out wrong is a kept promise, and you close it
|
|
173
|
+
COMPLETED. That is the protocol working as designed, not a failure you should
|
|
174
|
+
correct. Judging the truth of a claim is not your role; judging whether the
|
|
175
|
+
promised act occurred is.
|
|
176
|
+
|
|
177
|
+
**When the ask asks for a judgement.** Some asks want more than an act. "Name
|
|
178
|
+
the wallet behind this payment and show the transactions that prove it" is only performed by a
|
|
179
|
+
report that is *right*, and deciding that would mean redoing the
|
|
180
|
+
investigation yourself.
|
|
181
|
+
|
|
182
|
+
You do not. The party who commissioned the work decides whether it was good,
|
|
183
|
+
and they say so by paying. So:
|
|
184
|
+
|
|
185
|
+
- **The promise leg is attested** — the commissioner paid. They examined the
|
|
186
|
+
work and accepted it. Verify the payment as you would any other and close
|
|
187
|
+
COMPLETED. You are not second-guessing their acceptance.
|
|
188
|
+
- **The promise leg is empty and the deadline has passed** — they did not
|
|
189
|
+
accept it, or did not act. The covenant is FAILED.
|
|
190
|
+
- **The promise leg is empty and the deadline has not passed** — they are
|
|
191
|
+
still deciding. Leave it open.
|
|
192
|
+
|
|
193
|
+
This keeps the division clean. The commissioner judges whether the work was
|
|
194
|
+
good. You judge whether the acts occurred. Nobody judges whether a claim is
|
|
195
|
+
true, because nothing in this protocol can.
|
|
196
|
+
|
|
197
|
+
One consequence worth knowing: a refusal leaves no record. A commissioner who
|
|
198
|
+
reads a report, finds it wrong and declines to pay produces exactly the same
|
|
199
|
+
chain state as one who simply stopped responding — an unattested promise and
|
|
200
|
+
a clock running out. You cannot tell them apart and should not try.
|
|
201
|
+
|
|
202
|
+
**A failed lookup is not proof of absence.** A rate limit, an expired token or
|
|
203
|
+
a network error means you could not check — say so and leave the covenant
|
|
204
|
+
open. Only close FAILED when you positively established the post is not there.
|
|
205
|
+
|
|
206
|
+
### An overdue promise
|
|
207
|
+
|
|
208
|
+
A promise is only due once you have set status `1`, so its clock starts then,
|
|
209
|
+
not at `askSettledAt`: the minter cannot be late for a promise you had not yet
|
|
210
|
+
made due. Find the moment in the contract's `CovenantPromiseSet(uint256
|
|
211
|
+
indexed nftId, uint8 status)` event (topic0
|
|
212
|
+
`0xdcf652774da7b54e1effef25c4a189987f100c4d96e0cd4bf05928c44294b6a8`) — the
|
|
213
|
+
block of the latest log for this covenant with status `1`:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
cast rpc eth_getLogs '{"address":"<KUDO>","fromBlock":"<hex>","toBlock":"latest","topics":["0xdcf652774da7b54e1effef25c4a189987f100c4d96e0cd4bf05928c44294b6a8","<nftId as 32-byte hex>"]}' --rpc-url $RPC_URL
|
|
217
|
+
cast block <blockNumber> --field timestamp --rpc-url $RPC_URL
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A covenant still at `0` has no promise due and cannot be overdue. Then read
|
|
221
|
+
the promise's timing words against that moment:
|
|
222
|
+
|
|
223
|
+
- a **deadline** — "within one hour" — is broken once that long has passed
|
|
224
|
+
with nothing attested
|
|
225
|
+
- a **term** — "after 30 days" — is not due until it has elapsed, and nothing
|
|
226
|
+
is broken before then
|
|
227
|
+
|
|
228
|
+
A promise past its deadline with no settlement data is FAILED. A promise still
|
|
229
|
+
inside its window is not yet anything — leave it.
|
|
230
|
+
|
|
231
|
+
## Before you close
|
|
232
|
+
|
|
233
|
+
Say what you checked and what you found, for each leg, with the figures. A
|
|
234
|
+
close is permanent and the reasoning should be legible to whoever reads the
|
|
235
|
+
covenant afterwards.
|
|
236
|
+
|
|
237
|
+
If you cannot establish a leg either way, leave the covenant open and say what
|
|
238
|
+
you were unable to verify. An unjudged covenant is recoverable. A wrongly
|
|
239
|
+
burned one is not.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: twitter
|
|
3
|
+
description: Post tweets from this agent's X account and read back the posted URL. Use whenever a task involves tweeting, posting to X or Twitter, or producing the tweet URL that settles a covenant's leg. Covers OAuth 1.0a request signing with no dependencies, the pre-flight that catches a read-only token, and what each 403 actually means.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Posting to X
|
|
7
|
+
|
|
8
|
+
This agent posts through the X API v2 directly. There is no MCP server and no
|
|
9
|
+
helper library — requests are signed with OAuth 1.0a using only `node:crypto`,
|
|
10
|
+
which is available in the image.
|
|
11
|
+
|
|
12
|
+
## Credentials
|
|
13
|
+
|
|
14
|
+
| Variable | From |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `TWITTER_API_KEY` | app consumer key |
|
|
17
|
+
| `TWITTER_API_SECRET_KEY` | app consumer secret |
|
|
18
|
+
| `TWITTER_ACCESS_TOKEN` | user access token |
|
|
19
|
+
| `TWITTER_ACCESS_TOKEN_SECRET` | user access token secret |
|
|
20
|
+
| `TWITTER_HANDLE` | the account's handle, used to build tweet URLs |
|
|
21
|
+
|
|
22
|
+
All four credentials are required. If any one is missing or empty the request
|
|
23
|
+
is unsigned and X answers `403 Unsupported Authentication` — the error says
|
|
24
|
+
"Authenticating with Unknown", which looks like a permissions problem but is
|
|
25
|
+
not. Check all four are present before anything else.
|
|
26
|
+
|
|
27
|
+
## Pre-flight: is this token allowed to post?
|
|
28
|
+
|
|
29
|
+
Run this before the first post of a session. It is a read, and it reports the
|
|
30
|
+
token's own access level — which is the only reliable way to know whether a
|
|
31
|
+
post will be refused.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
node -e '
|
|
35
|
+
const crypto = require("crypto");
|
|
36
|
+
const e = encodeURIComponent;
|
|
37
|
+
const url = "https://api.twitter.com/1.1/account/verify_credentials.json";
|
|
38
|
+
const o = {
|
|
39
|
+
oauth_consumer_key: process.env.TWITTER_API_KEY,
|
|
40
|
+
oauth_nonce: crypto.randomBytes(16).toString("hex"),
|
|
41
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
42
|
+
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
|
|
43
|
+
oauth_token: process.env.TWITTER_ACCESS_TOKEN,
|
|
44
|
+
oauth_version: "1.0",
|
|
45
|
+
};
|
|
46
|
+
const base = "GET&" + e(url) + "&" + e(Object.keys(o).sort().map(k => e(k) + "=" + e(o[k])).join("&"));
|
|
47
|
+
const key = e(process.env.TWITTER_API_SECRET_KEY) + "&" + e(process.env.TWITTER_ACCESS_TOKEN_SECRET);
|
|
48
|
+
o.oauth_signature = crypto.createHmac("sha1", key).update(base).digest("base64");
|
|
49
|
+
const header = "OAuth " + Object.keys(o).sort().map(k => e(k) + "=\"" + e(o[k]) + "\"").join(", ");
|
|
50
|
+
fetch(url, { headers: { Authorization: header } }).then(async r => {
|
|
51
|
+
const body = await r.json();
|
|
52
|
+
console.log("status:", r.status, "| access-level:", r.headers.get("x-access-level"), "| @" + (body.screen_name || "?"));
|
|
53
|
+
});'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`x-access-level` must read **`read-write`**. If it reads `read`, posting will
|
|
57
|
+
fail no matter what the developer portal shows — see the 403 section below.
|
|
58
|
+
|
|
59
|
+
## Posting
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
node -e '
|
|
63
|
+
const crypto = require("crypto");
|
|
64
|
+
const e = encodeURIComponent;
|
|
65
|
+
const text = process.argv[1];
|
|
66
|
+
const url = "https://api.twitter.com/2/tweets";
|
|
67
|
+
const o = {
|
|
68
|
+
oauth_consumer_key: process.env.TWITTER_API_KEY,
|
|
69
|
+
oauth_nonce: crypto.randomBytes(16).toString("hex"),
|
|
70
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
71
|
+
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
|
|
72
|
+
oauth_token: process.env.TWITTER_ACCESS_TOKEN,
|
|
73
|
+
oauth_version: "1.0",
|
|
74
|
+
};
|
|
75
|
+
// JSON body parameters are NOT part of the OAuth signature base string.
|
|
76
|
+
const base = "POST&" + e(url) + "&" + e(Object.keys(o).sort().map(k => e(k) + "=" + e(o[k])).join("&"));
|
|
77
|
+
const key = e(process.env.TWITTER_API_SECRET_KEY) + "&" + e(process.env.TWITTER_ACCESS_TOKEN_SECRET);
|
|
78
|
+
o.oauth_signature = crypto.createHmac("sha1", key).update(base).digest("base64");
|
|
79
|
+
const header = "OAuth " + Object.keys(o).sort().map(k => e(k) + "=\"" + e(o[k]) + "\"").join(", ");
|
|
80
|
+
fetch(url, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { Authorization: header, "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ text }),
|
|
84
|
+
}).then(async r => {
|
|
85
|
+
const body = await r.json();
|
|
86
|
+
if (!r.ok) { console.error("FAILED", r.status, JSON.stringify(body)); process.exit(1); }
|
|
87
|
+
console.log("https://twitter.com/" + process.env.TWITTER_HANDLE + "/status/" + body.data.id);
|
|
88
|
+
});' "<tweet text here>"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
It prints the tweet URL and nothing else, so the URL can be used directly as a
|
|
92
|
+
covenant's settlement data. Pass the text as an argument rather than
|
|
93
|
+
interpolating it into the script — tweets contain quotes, apostrophes and em
|
|
94
|
+
dashes that will otherwise break the shell or the JavaScript.
|
|
95
|
+
|
|
96
|
+
## Writing the tweet
|
|
97
|
+
|
|
98
|
+
- 280 characters maximum. Count before posting; a rejected post still costs a
|
|
99
|
+
round trip.
|
|
100
|
+
- X refuses exact duplicates of a recent tweet. Vary the text rather than
|
|
101
|
+
retrying the same string.
|
|
102
|
+
- Write the tweet the ask actually describes. If the ask names a subject
|
|
103
|
+
("tweet about sub-second finality"), the tweet must be about that subject —
|
|
104
|
+
a covenant's ask is the record of what was owed.
|
|
105
|
+
- Only assert things that are true. If a tweet would state a figure, either
|
|
106
|
+
have measured it or leave it out.
|
|
107
|
+
|
|
108
|
+
## What the 403s mean
|
|
109
|
+
|
|
110
|
+
| Response | Cause | Fix |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `"Your client app is not configured with the appropriate oauth1 app permissions"` | the **token** is read-only | In the developer portal set the app to *Read and write*, then **regenerate the access token and secret**. Changing the setting alone does nothing — an existing token keeps the permission it was minted under. The consumer key and secret stay the same. |
|
|
113
|
+
| `"Authenticating with Unknown is forbidden"` | one or more credentials missing or empty | Check all four variables are set and non-empty; a mangled env file produces this. |
|
|
114
|
+
| `401` on the pre-flight | wrong secret, or signature built wrong | Confirm the signing key is `consumer_secret & token_secret`, both URL-encoded. |
|
|
115
|
+
|
|
116
|
+
The permissions case is the common one and the fix is counter-intuitive: the
|
|
117
|
+
portal can show *Read and write* while the token in hand is still `read`. Trust
|
|
118
|
+
`x-access-level`, not the portal.
|
|
119
|
+
|
|
120
|
+
## After posting
|
|
121
|
+
|
|
122
|
+
The tweet URL is the deliverable. When a covenant's ask or promise was a tweet,
|
|
123
|
+
that URL is what gets written as settlement data — see the kudo-runtime
|
|
124
|
+
skill for which party signs which leg.
|
|
125
|
+
|
|
126
|
+
Never claim a tweet was posted without a URL from a successful response, and
|
|
127
|
+
never reuse an older tweet's URL to settle a new covenant.
|