The second way a decision binds to action: a decided proposal becomes an issue on a named repository, and never a second one.
What done looks like
[ ] The target repository (owner/name) and an on/off switch are settings an admin keeps, in the setting table; the token comes from the environment (FORGE_API_TOKEN via src/env.ts), never the database and never the repository. With no token the page says the binding is off and names the variable that turns it on.
[ ] owner and name are validated at the boundary: [A-Za-z0-9_.-]{1,64} each, no scheme, no slash, no path — anything else is invalid with a sentence.
[ ] A migration adds issue_number and issue_url to proposal.
[ ] src/forge.ts posts one issue to ${FORGE_URL}/api/v1/repos/<owner>/<name>/issues when decision.made fires, after the response: a title from the proposal's title truncated to 120 characters, and a body carrying the paragraph, the winning option, the tally's reason and a link back to /p/:id on this app's public origin. Title and body travel as JSON values, never interpolated into a URL and never near a shell.
[ ] The proposal records issue_number and issue_url in the same write that marks it bound; a proposal that already has one opens nothing, whatever fires again. An admin's retry is the only second attempt, and it reuses the recorded number rather than creating another issue.
[ ] Failures land in the same delivery log as the webhook, with kind = 'issue', retriable by an admin only.
[ ] /p/:id shows the opened issue as a link on a decided proposal; a guest sees the link too.
[ ] Tests, against a stub server standing in for the forge API: the body carries the winner and the tally; a bad owner/name is refused before any request; a second decision.made for the same proposal sends nothing; with no token nothing is attempted and the page says the binding is off; the token appears in no delivery row and no log line.
[ ] README.md says which environment variables turn each binding on and what the opened issue looks like. bun test is green.
Files I expect to change:src/db.ts, src/env.ts, src/actions.ts, src/forge.ts (new), src/forge.test.ts (new), src/deliver.ts, src/routes.ts, src/pages/group.ts, src/pages/proposal.ts, README.md.
Risks: a token that can write issues on a repository is the most dangerous secret this app touches — read it at the moment of use, keep it out of every row, error and log line, and send it to the forge's own origin and nowhere else. Deduplicate on the proposal row rather than in memory, or a restart mid-flight opens a duplicate. Worth asking Sentinel to review before it merges.
Fit: Ledger — an idempotency key on anything that could double-fire, and an integration an auditor could follow. A person assigns; I do not.
The second way a decision binds to action: a decided proposal becomes an issue on a named repository, and never a second one.
What done looks like
owner/name) and an on/off switch are settings an admin keeps, in thesettingtable; the token comes from the environment (FORGE_API_TOKENviasrc/env.ts), never the database and never the repository. With no token the page says the binding is off and names the variable that turns it on.ownerandnameare validated at the boundary:[A-Za-z0-9_.-]{1,64}each, no scheme, no slash, no path — anything else isinvalidwith a sentence.issue_numberandissue_urltoproposal.src/forge.tsposts one issue to${FORGE_URL}/api/v1/repos/<owner>/<name>/issueswhendecision.madefires, after the response: a title from the proposal's title truncated to 120 characters, and a body carrying the paragraph, the winning option, the tally'sreasonand a link back to/p/:idon this app's public origin. Title and body travel as JSON values, never interpolated into a URL and never near a shell.issue_numberandissue_urlin the same write that marks it bound; a proposal that already has one opens nothing, whatever fires again. An admin's retry is the only second attempt, and it reuses the recorded number rather than creating another issue.deliverylog as the webhook, withkind = 'issue', retriable by an admin only./p/:idshows the opened issue as a link on a decided proposal; a guest sees the link too.owner/nameis refused before any request; a seconddecision.madefor the same proposal sends nothing; with no token nothing is attempted and the page says the binding is off; the token appears in nodeliveryrow and no log line.README.mdsays which environment variables turn each binding on and what the opened issue looks like.bun testis green.Files I expect to change:
src/db.ts,src/env.ts,src/actions.ts,src/forge.ts(new),src/forge.test.ts(new),src/deliver.ts,src/routes.ts,src/pages/group.ts,src/pages/proposal.ts,README.md.Risks: a token that can write issues on a repository is the most dangerous secret this app touches — read it at the moment of use, keep it out of every row, error and log line, and send it to the forge's own origin and nowhere else. Deduplicate on the proposal row rather than in memory, or a restart mid-flight opens a duplicate. Worth asking Sentinel to review before it merges.
Fit: Ledger — an idempotency key on anything that could double-fire, and an integration an auditor could follow. A person assigns; I do not.