diff --git a/design/notes/dasl-wg-template-upgrade.md b/design/notes/dasl-wg-template-upgrade.md new file mode 100644 index 0000000..9204a1c --- /dev/null +++ b/design/notes/dasl-wg-template-upgrade.md @@ -0,0 +1,111 @@ +# DASL WG template upgrade (operator note) + +> 2026-08-25. The live DASL WG (and the live root polity) were chartered by +> the phase-2 deployment, before the Phase 4 machinery existed. Rules are +> records, not code: deploying a new kernel changes nothing about a polity's +> charter. Upgrades reach a live polity only as change proposals decided by +> that polity's own rules. This is now built in — this note is the procedure +> plus the expected payloads for review. + +## The built-in mechanism + +`es.politi.templateUpgrades?polity=…` compares a polity's in-force machinery +works (`arena-*`, `pos-*`, `catalog-*` — never the charter text or +deliverables) against the current platform template by canonical CID, with +polity-local wiring (an arena's external `scope`) preserved so it never reads +as drift. Each gap is returned as a ready-to-file change-proposal payload. +The polity page shows members a **Template upgrades available** section with +a *Propose adoption* button per item. Custom charters (no template spine) are +offered nothing. + +## Procedure + +1. Deploy the current build (`polities deploy`, per the prod runbook). +2. Open the **DASL WG** page signed in as a member. Expect two offers + (payloads below): the roles arena (new) and the motions arena (new). + Propose adoption on both. Each files a change proposal routed to + **arena-charter**: consent of the members, **14-day** window; silence + passes, a valid objection blocks. +3. Open the **politi.es** (root) page. Expect three catalog offers — + `catalog-majority`, `catalog-approval`, `catalog-schulze` — routed to + **arena-catalog**: steward decides (dictator ballot in the UI), so they + certify as soon as you adopt. +4. The section empties as adoptions certify; `polities verify` confirms the + enactments like any other decision. + +The list the live page shows is authoritative — it is computed against the +actual journal. The payloads below are what it will offer if the live WG +matches the phase-2 seed, recorded here for review before filing. + +## Expected DASL WG payloads + +**arena-roles** (new) — grants/revokes of `participant`/`editor`/`facilitator` +by consent of members, 7-day window; grants require a member subject, +revocations a current holder; revocation sheds the position, membership +survives. + +```json +{ + "work": "arena-roles", + "governedBy": "arena-charter", + "title": "Roles arena", + "body": { + "$type": "es.politi.ruleVersion#arena", + "arena": { + "name": "roles", + "spawn": { "collection": "es.politi.proposal", "kind": "role" }, + "maySpawn": { "$type": "es.politi.defs#members" }, + "eligible": { "$type": "es.politi.defs#members" }, + "binding": { + "primitive": "consent", + "params": {}, + "windowMs": 604800000, + "noAgreement": "statusQuo" + } + } + } +} +``` + +**arena-motions** (new) — contested alternatives on one Schulze instance, +7-day window; any member may add alternatives while ballots are open +(agenda-setting as acts); delegable; 48h grace with exit rights (ragequit) +before effects bind. + +```json +{ + "work": "arena-motions", + "governedBy": "arena-charter", + "title": "Motions arena", + "body": { + "$type": "es.politi.ruleVersion#arena", + "arena": { + "name": "motions", + "spawn": { "collection": "es.politi.proposal", "kind": "motion" }, + "maySpawn": { "$type": "es.politi.defs#members" }, + "eligible": { "$type": "es.politi.defs#members" }, + "agenda": { "mayAdd": { "$type": "es.politi.defs#members" } }, + "binding": { + "primitive": "schulze", + "params": {}, + "windowMs": 604800000, + "noAgreement": "statusQuo", + "delegable": true, + "exitRight": true + }, + "graceMs": 172800000 + } + } +} +``` + +## Notes + +- Adoption proposals are member acts: they live in the proposer's repo, so + they must be filed through the UI (or any client writing to your repo) — + the kernel cannot fabricate them, by design. +- If the WG has members beyond the founder, the 14-day consent window is + their chance to object; that is the feature working, not a delay to route + around. +- Future template evolutions ship the same way: extend the template in + `kernel/src/charters.ts`, and every templated polity sees the offer. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 87c25ca..59b22aa 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -37,6 +37,9 @@ export const getPolity = (did: string) => query query<{ polity: string; deliverables: import('./types.js').Deliverable[] }>('listDeliverables', { polity }); +export const templateUpgrades = (polity: string) => + query<{ polity: string; upgrades: import('./types.js').TemplateUpgrade[] }>('templateUpgrades', { polity }); + export const getWork = (polity: string, work: string) => query('getWork', { polity, work }); diff --git a/frontend/src/components/pol-polity-page.ts b/frontend/src/components/pol-polity-page.ts index 23b32ef..39b7b53 100644 --- a/frontend/src/components/pol-polity-page.ts +++ b/frontend/src/components/pol-polity-page.ts @@ -3,7 +3,7 @@ import { SignalWatcher } from '@lit-labs/signals'; import * as api from '../api.js'; import * as auth from '../auth.js'; import { app } from '../store.js'; -import type { Deliverable, Issue, OpenDecision, PendingDecision, PolityDetail, RepoIssue, WorkDetail } from '../types.js'; +import type { Deliverable, Issue, OpenDecision, PendingDecision, PolityDetail, RepoIssue, TemplateUpgrade, WorkDetail } from '../types.js'; import { fmtDate } from '../format.js'; import './pol-actor.js'; import './pol-ballot.js'; @@ -220,6 +220,14 @@ export class PolPolityPage extends SignalWatcher(LitElement) { flex-direction: column; gap: 0.6rem; } + .upgrade { + display: flex; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; + border: 2px solid var(--db); + padding: 0.5rem 0.8rem; + } form.inline { display: flex; align-items: center; @@ -242,6 +250,7 @@ export class PolPolityPage extends SignalWatcher(LitElement) { private decisions: OpenDecision[] = []; private pending: PendingDecision[] = []; private charter: WorkDetail | null = null; + private upgrades: TemplateUpgrade[] = []; private error: string | null = null; private issueOpen = false; private submitted: string | null = null; @@ -484,6 +493,7 @@ export class PolPolityPage extends SignalWatcher(LitElement) { this.charter = await api .getWork(this.did, String(detail.record.charterWork ?? 'charter')) .catch(() => null); + this.upgrades = (await api.templateUpgrades(this.did).catch(() => ({ upgrades: [] }))).upgrades; } catch (err) { this.error = err instanceof Error ? err.message : String(err); } @@ -515,6 +525,36 @@ export class PolPolityPage extends SignalWatcher(LitElement) { ` : nothing} + ${this.upgrades.length && this.isMember(d) + ? html`
+

Template upgrades available

+
+ the platform template has machinery this polity predates. These are offers, not + obligations: each files as a change proposal decided by this polity's own rules. +
+ ${this.upgrades.map( + (u) => html`
+ ${u.status} + ${u.title} + ${u.work} + decided in ${u.decidedBy} + +
`, + )} +
` + : nothing} ${this.deliverables.length || this.isMember(d) ? html`

Deliverables

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8af01c9..c3741f3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -79,6 +79,16 @@ export type PrimitiveSheet = { export type ModifierSheet = { id: string } & Record; +// machinery this polity lacks vs the current platform template, as a +// ready-to-file change proposal; adoption is the polity's own decision +export type TemplateUpgrade = { + work: string; + title: string; + status: 'new' | 'changed'; + decidedBy: string; + payload: Record; +}; + // a certified decision sitting in its grace window (exit rights may apply) export type PendingDecision = { decision: string; diff --git a/kernel/src/server.ts b/kernel/src/server.ts index 54d148f..574df24 100644 --- a/kernel/src/server.ts +++ b/kernel/src/server.ts @@ -246,6 +246,14 @@ export async function serve(opts: ServeOpts): Promise<{ server: Server; pk: Pers seed: wgTemplate(founder, { name, purpose, deliverables }), }); } + case 'es.politi.templateUpgrades': { + // machinery this polity lacks vs the current template, as + // ready-to-file change proposals — adoption is the polity's decision + const polity = resolvePolity(url.searchParams.get('polity') ?? ''); + if (!pk.kernel.polity(polity)) return json(res, 404, { error: 'PolityNotFound' }); + const { templateUpgrades } = await import('./upgrades.ts'); + return json(res, 200, { polity, upgrades: await templateUpgrades(pk.kernel, polity) }); + } case 'es.politi.listPrimitives': { // the property-sheet catalog: what institution designers choose from (DESIGN §2.6) const { PROPERTIES, MODIFIERS } = await import('./charters.ts'); diff --git a/kernel/src/upgrades.ts b/kernel/src/upgrades.ts new file mode 100644 index 0000000..6963a9b --- /dev/null +++ b/kernel/src/upgrades.ts @@ -0,0 +1,72 @@ +/** + * Template upgrades: what a polity's machinery is missing relative to the + * current platform template, each gap shaped as a ready-to-file change + * proposal. Offers, never obligations — rules are records, not code, so a + * template evolution reaches a live polity only through that polity's own + * governed decision (the charter arena for arenas and positions, the catalog + * arena for catalog entries). + */ +import { cidOf } from './cid.ts'; +import type { Kernel } from './engine.ts'; +import { rootSeed, wgTemplate } from './charters.ts'; +import type { ArenaDef, RuleBody } from './statements.ts'; +import type { Did } from './types.ts'; + +export interface TemplateUpgrade { + work: string; + title: string; + status: 'new' | 'changed'; + /** the arena that will decide adoption (the change proposal's routing) */ + decidedBy: string; + /** ready-to-file es.politi.proposal kind=change payload */ + payload: { work: string; governedBy?: string; title?: string; body: RuleBody }; +} + +/** template machinery: arenas, positions, catalog entries — never the charter text or deliverables */ +const isMachinery = (rkey: string) => + rkey.startsWith('arena-') || rkey.startsWith('pos-') || rkey.startsWith('catalog-'); + +/** + * polity-local knobs survive the upgrade: an arena's external scope is the + * polity's own wiring (which repos it may act on), not template semantics — + * mirror the current scope into the template body before comparing and offering + */ +function preserveLocal(template: RuleBody, current: RuleBody): RuleBody { + if (template.$type !== 'es.politi.ruleVersion#arena' || current.$type !== 'es.politi.ruleVersion#arena') + return template; + const arena: ArenaDef = { ...template.arena }; + if (current.arena.scope !== undefined) arena.scope = current.arena.scope; + else delete arena.scope; + return { ...template, arena }; +} + +export async function templateUpgrades(kernel: Kernel, did: Did): Promise { + const p = kernel.polity(did); + if (!p) return []; + const isRoot = did === kernel.rootDid; + // only polities that carry the template's spine get its upgrades — a custom + // charter is not something we second-guess + if (!isRoot && (!p.works.has('arena-charter') || !p.works.has('pos-participant'))) return []; + const seed = isRoot + ? rootSeed(p.value.founder) + : wgTemplate(p.value.founder, { name: p.value.name, purpose: p.value.purpose }); + const out: TemplateUpgrade[] = []; + for (const w of seed.works) { + if (!isMachinery(w.rkey)) continue; + const current = kernel.workDetail(did, w.rkey); + const body = current ? preserveLocal(w.body, current.body) : w.body; + if (current && (await cidOf(body)) === (await cidOf(current.body))) continue; + out.push({ + work: w.rkey, + title: w.title, + status: current ? 'changed' : 'new', + decidedBy: current?.governedBy ?? w.governedBy, + payload: { + work: w.rkey, + ...(current ? {} : { governedBy: w.governedBy, title: w.title }), + body, + }, + }); + } + return out; +} diff --git a/kernel/test/server.test.ts b/kernel/test/server.test.ts index eb5b592..8cfdd4f 100644 --- a/kernel/test/server.test.ts +++ b/kernel/test/server.test.ts @@ -177,5 +177,11 @@ describe('the XRPC surface', () => { expect(tpl.seed.works.some((w) => w.rkey === 'kelp-atlas')).toBe(true); expect(tpl.seed.works.some((w) => w.rkey === 'arena-roles')).toBe(true); expect(tpl.seed.seats.every((s) => s.subject === 'did:example:xun')).toBe(true); + + // a polity founded from the current template has no upgrades pending + const ups = (await ( + await fetch(`${base}/es.politi.templateUpgrades?polity=did:polity:dasl-wg`) + ).json()) as { upgrades: unknown[] }; + expect(ups.upgrades).toEqual([]); }); }); diff --git a/kernel/test/upgrades.test.ts b/kernel/test/upgrades.test.ts new file mode 100644 index 0000000..ffc2cd5 --- /dev/null +++ b/kernel/test/upgrades.test.ts @@ -0,0 +1,108 @@ +/** + * Template upgrades: a polity founded from an older template is offered the + * missing machinery as change proposals, adopts them through its own charter + * arena, and a current polity is offered nothing — polity-local wiring (an + * arena's external scope) never reads as drift. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { cidOf } from '../src/cid.ts'; +import { Kernel, type CharterSeed } from '../src/engine.ts'; +import { rootSeed, wgSeed } from '../src/charters.ts'; +import type { ProposalValue } from '../src/model.ts'; +import { templateUpgrades } from '../src/upgrades.ts'; +import { DAY, iso, millis, type Did, type Enveloped, type Nsid } from '../src/types.ts'; + +const T0 = millis('2026-08-25T00:00:00.000Z'); +const ROBIN: Did = 'did:example:robin'; +const dids = (name: string) => `did:polity:${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + +let kernel: Kernel; +let seq = 0; + +beforeEach(async () => { + seq = 0; + kernel = new Kernel(dids); + await kernel.advance(T0); + await kernel.genesis('politi.es', 'commons', ROBIN, rootSeed(ROBIN)); +}); + +async function act(did: Did, collection: Nsid, value: V, at: number): Promise> { + const env: Enveloped = { did, collection, rkey: `t-${++seq}`, value, cid: await cidOf(value), at }; + Object.assign(env as object, await kernel.apply(env as Enveloped)); + return env; +} + +/** the DASL WG as the phase-2 deployment knew it: no motions arena, no roles arena */ +function phase2Seed(): CharterSeed { + const seed = wgSeed(ROBIN); + return { ...seed, works: seed.works.filter((w) => w.rkey !== 'arena-motions' && w.rkey !== 'arena-roles') }; +} + +async function found(seed: CharterSeed, at: number): Promise { + await act(ROBIN, 'es.politi.proposal', { + polity: 'did:polity:politi-es', kind: 'founding', payload: seed, createdAt: iso(at), + }, at); + await kernel.advance(at + 1); + return 'did:polity:dasl-wg'; +} + +describe('templateUpgrades', () => { + it('offers a phase-2 polity exactly the machinery it lacks, as filable payloads', async () => { + const wg = await found(phase2Seed(), T0 + DAY); + const ups = await templateUpgrades(kernel, wg); + expect(ups.map((u) => ({ work: u.work, status: u.status, decidedBy: u.decidedBy }))).toEqual([ + { work: 'arena-roles', status: 'new', decidedBy: 'arena-charter' }, + { work: 'arena-motions', status: 'new', decidedBy: 'arena-charter' }, + ]); + for (const u of ups) { + expect(u.payload.work).toBe(u.work); + expect(u.payload.governedBy).toBe('arena-charter'); + expect(u.payload.body.$type).toBe('es.politi.ruleVersion#arena'); + } + }); + + it('a current polity is offered nothing — local external scope never reads as drift', async () => { + // wgSeed wires external effects (closeIssue/createIssue) that the bare + // template does not; preserveLocal keeps that from surfacing as an upgrade + const wg = await found(wgSeed(ROBIN), T0 + DAY); + expect(await templateUpgrades(kernel, wg)).toEqual([]); + // the root polity is compared against rootSeed, and is also current + expect(await templateUpgrades(kernel, 'did:polity:politi-es')).toEqual([]); + }); + + it('adoption runs through the charter arena and clears the offer; the arena then works', async () => { + const wg = await found(phase2Seed(), T0 + DAY); + const ups = await templateUpgrades(kernel, wg); + // file both offers as change proposals (consent of members, 14d each) + let t = T0 + 2 * DAY; + for (const u of ups) { + const filed = await act(ROBIN, 'es.politi.proposal', { + polity: wg, kind: 'change', payload: u.payload, createdAt: iso(t), + }, t); + expect((filed as unknown as { recognized: boolean }).recognized).toBe(true); + t += 1; + } + await kernel.advance(t + 14 * DAY + 1); + expect(await templateUpgrades(kernel, wg)).toEqual([]); + // the freshly chartered roles arena decides a real role proposal + const grant = await act(ROBIN, 'es.politi.proposal', { + polity: wg, kind: 'role', payload: { subject: ROBIN, position: 'editor' }, createdAt: iso(kernel.now + 1), + }, kernel.now + 1); + expect((grant as unknown as { recognized: boolean }).recognized).toBe(true); + }); + + it('a polity without the template spine is offered nothing', async () => { + const seed = wgSeed(ROBIN); + const custom: CharterSeed = { + ...seed, + name: 'Bespoke', + works: seed.works.filter((w) => w.rkey !== 'pos-participant'), + seats: [{ position: 'facilitator', subject: ROBIN }], + }; + await act(ROBIN, 'es.politi.proposal', { + polity: 'did:polity:politi-es', kind: 'founding', payload: custom, createdAt: iso(T0 + DAY), + }, T0 + DAY); + await kernel.advance(T0 + DAY + 1); + expect(await templateUpgrades(kernel, 'did:polity:bespoke')).toEqual([]); + }); +});