Skip to content

Commit 0bd8048

Browse files
MaxBrychclaude
andcommitted
feat(publisher): proposal pointers — kind 32100 joins record, Irys and chain
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ea77ddb commit 0bd8048

6 files changed

Lines changed: 106 additions & 5 deletions

File tree

docs/PUBLIC_DATA_ON_NOSTR.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public datasets onto the relay every 5 minutes — first live pass: 41 events ac
2929
| **town news** — NIP-23 `30023`, `d=news:<uuid>`, town-signed, slug tag for routing | |
3030
| **restaurant menus** — kind `32101` (`restaurant:<id>`), one replaceable event per restaurant, prices as raw decimals + EUR | |
3131
| **civic notices** — kind `32102` (`alert:<id>` or `announcement:<id>`), town-signed, resolved alerts are edits (never deleted) | |
32+
| **governance proposals** — kind `32100` (`proposal:<id>`), a discoverable pointer: body on Irys, state on-chain, status snapshot in tags | |
3233
| **marketplace** — NIP-15 `30018`, seller opt-in gated (unrevoked npub binding), withdrawal-as-edit | |
3334
| **business deals** — NIP-99 `30402`, offers signed under the business's derived key | |
3435
| **images** — mirrored content-addressed at `/media/<sha256>` (Blossom-shaped reads); the hash in the signed event is the integrity check | |

packages/protocol/examples/roebel.netizen.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,8 @@
209209
"businesses",
210210
"news",
211211
"notices",
212-
"menus"
212+
"menus",
213+
"proposals"
213214
],
214215
"intervalSeconds": 300,
215216
"backfeed": true

packages/publisher/src/cli.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ function required(name: string): string {
2323
return value;
2424
}
2525

26-
const VALID_DATASETS = new Set<DatasetName>(["events", "cinema", "orgs", "articles", "marketplace", "deals", "news", "businesses", "notices", "menus"]);
26+
const VALID_DATASETS = new Set<DatasetName>(["events", "cinema", "orgs", "articles", "marketplace", "deals", "news", "businesses", "notices", "menus", "proposals"]);
2727

2828
async function main(): Promise<void> {
2929
const nodeId = required("NODE_ID");
@@ -40,7 +40,7 @@ async function main(): Promise<void> {
4040
.map((d) => d.trim())
4141
.filter((d): d is DatasetName => VALID_DATASETS.has(d as DatasetName));
4242
if (datasets.length === 0) {
43-
console.error("PUBLISH_DATASETS names no known dataset (events, cinema, orgs, articles, marketplace, deals, news, businesses, notices, menus)");
43+
console.error("PUBLISH_DATASETS names no known dataset (events, cinema, orgs, articles, marketplace, deals, news, businesses, notices, menus, proposals)");
4444
process.exit(2);
4545
}
4646

@@ -54,6 +54,8 @@ async function main(): Promise<void> {
5454

5555
console.log(`publisher for "${nodeId}" -> ${relayUrl}; datasets: ${datasets.join(", ")}`);
5656

57+
const governor = process.env.PROPOSAL_GOVERNOR;
58+
5759
// Content-addressed media mirror: images referenced by published events are
5860
// fetched once, stored by sha256 beside a content-type sidecar, and the
5961
// event's URL is rewritten to the node's own /media/<sha>. Any failure keeps
@@ -147,6 +149,7 @@ async function main(): Promise<void> {
147149
datasets,
148150
fetchRows,
149151
relayUrl,
152+
...(governor ? { governor } : {}),
150153
...(mirrorMedia ? { mirrorMedia } : {}),
151154
// Announce signing keys BEFORE publishing, atomically — the allow-list
152155
// syncer reads this file on its own schedule and must never see a half

packages/publisher/src/mappers.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,52 @@ export function noticeToSpec(
586586
/** Netizen menu — see the fork-with-fallback spec §3.2. */
587587
export const KIND_MENU = 32101;
588588

589+
/** Netizen proposal metadata — see the fork-with-fallback spec §3.2. */
590+
export const KIND_PROPOSAL_META = 32100;
591+
592+
/**
593+
* A governance proposal → a discoverable pointer on the record.
594+
*
595+
* The body is already permanent on Irys and the authoritative state (votes,
596+
* tallies, execution) lives on-chain; this event makes both findable and
597+
* joinable from the record. The `status` tag is a SNAPSHOT for list rendering
598+
* — clients needing truth read the Governor. The proposer's wallet is
599+
* deliberately absent: it is on-chain for those who need it, and the record
600+
* never carries raw addresses.
601+
*/
602+
export function proposalToSpec(row: Row, governor: string): PublishSpec | null {
603+
if (!governor) return null;
604+
const proposalId = str(row, "proposal_id");
605+
const title = str(row, "title");
606+
if (!proposalId || !title) return null;
607+
608+
const tags: string[][] = [
609+
["d", `proposal:${proposalId}`],
610+
["title", title],
611+
["governor", governor],
612+
["t", "proposal"],
613+
];
614+
const chainId = str(row, "blockchain_proposal_id");
615+
if (chainId) tags.push(["proposal_id", chainId]);
616+
const irys = str(row, "irys_content_id");
617+
if (irys) tags.push(["irys", irys]);
618+
const category = str(row, "category");
619+
if (category) tags.push(["t", category]);
620+
const state = row["state"];
621+
if (state !== null && state !== undefined) tags.push(["status", String(state)]);
622+
const createdAt = str(row, "created_at");
623+
if (createdAt) tags.push(["published_at", String(Math.floor(Date.parse(createdAt) / 1000))]);
624+
625+
return {
626+
scope: TOWN_SCOPE,
627+
kind: KIND_PROPOSAL_META,
628+
d: `proposal:${proposalId}`,
629+
content: str(row, "summary") ?? "",
630+
tags,
631+
createdAt: unixFromUpdatedAt(row),
632+
};
633+
}
634+
589635
export interface MenuInput {
590636
restaurant: Row;
591637
categories: Row[];

packages/publisher/src/sync.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
noticeToSpec,
1919
orgPostToSpec,
2020
orgToSpec,
21+
proposalToSpec,
2122
type MenuInput,
2223
type PublishSpec,
2324
} from "./mappers.js";
@@ -64,6 +65,11 @@ export interface PublisherDeps {
6465
*/
6566
onPubkeys?: (pubkeys: string[]) => Promise<void>;
6667
log?: (message: string) => void;
68+
/**
69+
* Governor address for proposals ("100:0x5F5e…" format: chainId:address).
70+
* Required if datasets includes "proposals".
71+
*/
72+
governor?: string;
6773
}
6874

6975
export interface PublishSummary {
@@ -77,7 +83,7 @@ export interface PublishSummary {
7783

7884
/** Build the full spec list for one pass. Exposed for tests. */
7985
export async function buildSpecs(
80-
deps: Pick<PublisherDeps, "datasets" | "fetchRows" | "nodeId">,
86+
deps: Pick<PublisherDeps, "datasets" | "fetchRows" | "nodeId" | "governor">,
8187
): Promise<PublishSpec[]> {
8288
const specs: PublishSpec[] = [];
8389
const wantsOrgs = deps.datasets.includes("orgs");
@@ -264,6 +270,20 @@ export async function buildSpecs(
264270
if (spec) specs.push(spec);
265271
}
266272
}
273+
if (deps.datasets.includes("proposals")) {
274+
if (!deps.governor) {
275+
// Deliberately loud: a configured dataset that silently publishes nothing is a lie.
276+
throw new Error("datasets includes 'proposals' but PROPOSAL_GOVERNOR is not set");
277+
}
278+
const rows = await deps.fetchRows(
279+
"proposals",
280+
"select=id,proposal_id,blockchain_proposal_id,proposal_number,title,summary,category,irys_content_id,state,created_at,updated_at",
281+
);
282+
for (const row of rows) {
283+
const spec = proposalToSpec(row, deps.governor);
284+
if (spec) specs.push(spec);
285+
}
286+
}
267287
return specs;
268288
}
269289

packages/publisher/test/mappers.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
33
import { htmlToMarkdown } from "../src/html-to-md.js";
4-
import { articleToSpec, berlinToUnix, businessToSpec, dealToSpec, eventToSpec, listingToSpec, MAPPER_VERSION, menuToSpec, movieToSpec, newsToSpec, noticeToSpec, orgPostToSpec, orgToSpec } from "../src/mappers.js";
4+
import { articleToSpec, berlinToUnix, businessToSpec, dealToSpec, eventToSpec, listingToSpec, MAPPER_VERSION, menuToSpec, movieToSpec, newsToSpec, noticeToSpec, orgPostToSpec, orgToSpec, proposalToSpec } from "../src/mappers.js";
55

66
const ORG_ID = "11111111-1111-1111-1111-111111111111";
77
const ORGS = new Set([ORG_ID]);
@@ -566,3 +566,33 @@ describe("menu mapping", () => {
566566
assert.equal(menu.categories[1].name, "Another Active");
567567
});
568568
});
569+
570+
describe("proposal mapping", () => {
571+
it("proposalToSpec: a proposal becomes a discoverable pointer", () => {
572+
const spec = proposalToSpec(
573+
{ id: "p-row-1", proposal_id: "42", blockchain_proposal_id: "0xabc123", proposal_number: 7,
574+
title: "Neuer Spielplatz", summary: "Am Hafen", category: "infrastruktur",
575+
irys_content_id: "IRYS_TX_1", state: 1, created_at: "2026-07-01T10:00:00Z",
576+
updated_at: "2026-07-02T10:00:00Z",
577+
proposer_address: "0x5e6528DEADBEEF" },
578+
"100:0x5F5e499Dc1872c2Ce19a4b50cd10f680e78E3Ba3",
579+
);
580+
assert.ok(spec);
581+
assert.equal(spec!.kind, 32100);
582+
assert.equal(spec!.scope, "town");
583+
assert.equal(spec!.d, "proposal:42");
584+
assert.equal(spec!.content, "Am Hafen");
585+
const tag = (n: string) => spec!.tags.find((t) => t[0] === n)?.[1];
586+
assert.equal(tag("title"), "Neuer Spielplatz");
587+
assert.equal(tag("governor"), "100:0x5F5e499Dc1872c2Ce19a4b50cd10f680e78E3Ba3");
588+
assert.equal(tag("proposal_id"), "0xabc123");
589+
assert.equal(tag("irys"), "IRYS_TX_1");
590+
assert.equal(tag("status"), "1");
591+
// The proposer's wallet must NOT ride on the record event.
592+
assert.ok(!JSON.stringify(spec).includes("0x5e6528DEADBEEF"));
593+
});
594+
595+
it("proposalToSpec: no governor configured → nothing publishes", () => {
596+
assert.equal(proposalToSpec({ id: "x", proposal_id: "1", title: "t" }, ""), null);
597+
});
598+
});

0 commit comments

Comments
 (0)