Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ Stores mutable application state optimized for querying.

- Drafts
- Proposals
- Proposal comments
- AI reports
- Lore graph
- Search index
Expand Down Expand Up @@ -243,6 +244,58 @@ Default MVP:

These rules provide basic protection against Sybil attacks while keeping governance simple.

### Canon Voting MVP Rule

Submitted contributions enter `VOTING` immediately with `votingStartedAt` set by the backend and
`votingEndsAt = votingStartedAt + 48 hours`. The voting service never accepts client-supplied
voter identity, role, tally, result, or authoritative timestamps. Authenticated world members with
`VOTE_ON_PROPOSAL` may cast one vote per proposal and may change that vote only before
`votingEndsAt`.

Supported choices are `APPROVE`, `REJECT`, `NEEDS_REVISION`, and `ALTERNATE_TIMELINE`.
`totalVotes` counts all four choices. Approval arithmetic is exact integer basis points:
`approvalNumerator = APPROVE`, `approvalDenominator = APPROVE + REJECT`, and
`approvalPercentageBps = floor(APPROVE * 10000 / approvalDenominator)`, or `0` when the denominator
is zero. `NEEDS_REVISION` and `ALTERNATE_TIMELINE` count toward participation but do not enter the
approval denominator.

The MVP policy is centralized in `apps/api/src/lib/canon-voting-policy.ts`:

- Minimum eligible internal votes: 5.
- Approval threshold: 7000 basis points, so exactly 70% passes.
- Voting window: 48 hours.
- A proposal can pass only after the full window has ended.
- Founder or author status never bypasses the rule.
- A major AI warning remains attached and visible; it does not automatically reject a proposal.

If the approval rule does not pass after the window closes, the deterministic terminal outcome is
the plurality among `NEEDS_REVISION`, `ALTERNATE_TIMELINE`, and `REJECT`; ties resolve in that order.
If no failed-outcome choice has votes, the result is `REJECTED`. If the proposal's recorded base canon
version is stale at decision time, the outcome is `STALE_BASE_CONFLICT` and the proposal is surfaced
as needing revision instead of silently applying to newer canon.

Finalization creates one immutable `ProposalDecision` snapshot with per-choice counts, threshold
version, content hash, AI-warning state, branch/conflict metadata, and a deterministic decision
payload hash. Passing governance sets the proposal to `APPROVED_FOR_PUBLICATION`; it does not make a
`LoreEntry` `PUBLISHED_CANON`. Published canon still requires a separately confirmed Hive lore
post/comment and indexed `HiveReference`.

Approved decisions use a non-custodial Hive signing handoff. The API returns an unsigned posting
authority `custom_json` operation with HiveLore's stable custom JSON id (`hivelore`), schema version,
world ID, proposal ID, content hash, outcome, timestamps, tally, thresholds, AI-warning state, and
branch/base-canon references. The contributor signs and broadcasts through Hive Keychain or
HiveSigner; HiveLore never requests or stores private keys.

When a client reports broadcast details, the backend retrieves the operation through the configured
HAF/Hive indexing abstraction and verifies confirmation, operation type, custom JSON id, posting
signer, world/proposal/decision IDs, frozen tally/payload hash, timestamps, and duplicate operation
state before linking the result to `HiveEvent` and `ProposalDecision` idempotently.

Branching metadata keeps alternate continuations readable without deleting competing branches.
Alternate-timeline decisions are excluded from canon-only projections; canonizing one branch does not
remove other branches, which can later be revised, archived, merged, or retained under moderation
permissions.

## Hive Voting

Hive votes determine:
Expand Down Expand Up @@ -319,6 +372,30 @@ Auth challenge, verification, and refresh endpoints use Fastify rate limiting ba

Google auth routes are not registered as a default login path. With `GOOGLE_AUTH_ENABLED=false`, `/auth/google/*` returns a not-enabled response. Enabling Google currently exposes the guarded boundary only; Google OAuth account linking and automatic Hive provisioning remain deferred until secure non-custodial account creation and key delivery are specified.

Implemented world endpoints:

- `POST /worlds`: requires authentication, creates a mutable off-chain World Seed and initial `WorldBibleVersion` in one transaction, and assigns the authenticated user an active `FOUNDER` membership. The route never accepts a client-supplied founder or role and does not publish to Hive.
- `GET /worlds`: browses discoverable worlds with pagination and basic title/description, genre, and tone filters.
- `GET /worlds/:worldId`: returns one world with its founder, seed, and current latest World Bible version.
- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple canon/proposal stats, and latest canon lore entry summaries.
- `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive.

Implemented contribution endpoints:

- `POST /worlds/:worldId/contributions`: requires authentication and `CREATE_LORE_DRAFT` world permission, creates an author-owned structured contribution draft for lore or story content, and optionally links it to a lore entry in the same world.
- `GET /worlds/:worldId/contributions`: requires authentication and lists only the authenticated writer's contributions in the world with page/pageSize pagination and optional status/kind filters.
- `GET /worlds/:worldId/contributions/:contributionId`: returns an authenticated writer's own contribution draft or submitted contribution without exposing other writers' private drafts.
- `PATCH /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, updates only mutable draft fields, and rejects submitted contributions.
- `DELETE /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, deletes only draft contributions, and never deletes submitted proposals.
- `POST /worlds/:worldId/contributions/:contributionId/submit`: requires `SUBMIT_PROPOSAL`, atomically locks the draft, creates exactly one submitted proposal with an immutable `proposedContent` snapshot, and does not publish to Hive.

Implemented proposal discussion endpoints:

- `GET /worlds/:worldId/proposals/:proposalId/comments`: lists flat chronological off-chain proposal comments with cursor pagination. The proposal must belong to the route world. Deleted comments are returned as tombstones without the original body.
- `POST /worlds/:worldId/proposals/:proposalId/comments`: requires authentication and the existing `VOTE_ON_PROPOSAL` world permission, derives the author from the verified session, trims plain-text body content, and enforces a 3,000-character maximum.

Proposal comments are stored in PostgreSQL as mutable discussion records. They are not Hive comments, not immutable canon records, and never count as AppVote rows, approval totals, proposal outcomes, reputation, rewards, or canon status. Soft deletion preserves the discussion audit shape while normal API responses hide moderated comment bodies.

---

# Reputation & Rewards
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
CREATE TABLE "WorldSeed" (
"id" TEXT NOT NULL,
"worldId" TEXT NOT NULL,
"premise" TEXT NOT NULL,
"genre" TEXT NOT NULL,
"tone" TEXT NOT NULL,
"mainConflict" TEXT NOT NULL,
"startingLocation" TEXT,
"firstCharacters" JSONB,
"firstFactions" JSONB,
"firstHistoricalEvent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "WorldSeed_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "WorldSeed_worldId_key" ON "WorldSeed"("worldId");
CREATE INDEX "WorldSeed_genre_idx" ON "WorldSeed"("genre");
CREATE INDEX "WorldSeed_tone_idx" ON "WorldSeed"("tone");

ALTER TABLE "WorldSeed" ADD CONSTRAINT "WorldSeed_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- CreateEnum
CREATE TYPE "ContributionKind" AS ENUM ('LORE', 'STORY');

-- CreateEnum
CREATE TYPE "ContributionStatus" AS ENUM ('DRAFT', 'SUBMITTED');

-- AlterEnum
ALTER TYPE "ProposalType" ADD VALUE 'ADD_STORY';

-- AlterEnum
ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_CREATED';
ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_UPDATED';
ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_DELETED';
ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_SUBMITTED';
ALTER TYPE "WorldAuditAction" ADD VALUE 'PROPOSAL_CREATED';

-- AlterTable
ALTER TABLE "Proposal" ADD COLUMN "contributionKind" "ContributionKind";

-- CreateTable
CREATE TABLE "ContributionDraft" (
"id" TEXT NOT NULL,
"worldId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"targetLoreEntryId" TEXT,
"kind" "ContributionKind" NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT,
"content" JSONB NOT NULL,
"status" "ContributionStatus" NOT NULL DEFAULT 'DRAFT',
"proposalId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"submittedAt" TIMESTAMP(3),

CONSTRAINT "ContributionDraft_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "ContributionDraft_proposalId_key" ON "ContributionDraft"("proposalId");

-- CreateIndex
CREATE INDEX "ContributionDraft_worldId_idx" ON "ContributionDraft"("worldId");

-- CreateIndex
CREATE INDEX "ContributionDraft_authorId_idx" ON "ContributionDraft"("authorId");

-- CreateIndex
CREATE INDEX "ContributionDraft_worldId_status_idx" ON "ContributionDraft"("worldId", "status");

-- CreateIndex
CREATE INDEX "ContributionDraft_worldId_authorId_status_idx" ON "ContributionDraft"("worldId", "authorId", "status");

-- CreateIndex
CREATE INDEX "ContributionDraft_targetLoreEntryId_idx" ON "ContributionDraft"("targetLoreEntryId");

-- CreateIndex
CREATE INDEX "Proposal_worldId_contributionKind_idx" ON "Proposal"("worldId", "contributionKind");

-- AddForeignKey
ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_targetLoreEntryId_fkey" FOREIGN KEY ("targetLoreEntryId") REFERENCES "LoreEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TYPE "WorldAuditAction" ADD VALUE 'WORLD_BIBLE_UPDATED';
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-- CreateEnum
CREATE TYPE "ProposalDecisionOutcome" AS ENUM ('APPROVED_FOR_PUBLICATION', 'REJECTED', 'NEEDS_REVISION', 'ALTERNATE_TIMELINE', 'STALE_BASE_CONFLICT');

-- AlterEnum
ALTER TYPE "ProposalStatus" ADD VALUE IF NOT EXISTS 'NEEDS_REVISION';
ALTER TYPE "ProposalStatus" ADD VALUE IF NOT EXISTS 'ALTERNATE_TIMELINE';

-- AlterEnum
ALTER TYPE "VoteChoice" ADD VALUE IF NOT EXISTS 'NEEDS_REVISION';
ALTER TYPE "VoteChoice" ADD VALUE IF NOT EXISTS 'ALTERNATE_TIMELINE';

-- AlterEnum
ALTER TYPE "WorldAuditAction" ADD VALUE IF NOT EXISTS 'CANON_DECISION_FINALIZED';

-- AlterTable
ALTER TABLE "Proposal"
ADD COLUMN "votingEndsAt" TIMESTAMP(3),
ADD COLUMN "decidedAt" TIMESTAMP(3),
ADD COLUMN "contentHash" TEXT,
ADD COLUMN "aiWarningAcknowledgedAt" TIMESTAMP(3),
ADD COLUMN "baseCanonVersionId" TEXT,
ADD COLUMN "branchParentProposalId" TEXT,
ADD COLUMN "branchBaseLoreEntryId" TEXT,
ADD COLUMN "branchLabel" TEXT,
ADD COLUMN "conflictMetadata" JSONB,
ADD COLUMN "staleBaseAtDecision" BOOLEAN NOT NULL DEFAULT false;

-- CreateTable
CREATE TABLE "ProposalDecision" (
"id" TEXT NOT NULL,
"proposalId" TEXT NOT NULL,
"outcome" "ProposalDecisionOutcome" NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL,
"rulesVersion" TEXT NOT NULL,
"payloadSchemaVersion" INTEGER NOT NULL,
"minimumVotes" INTEGER NOT NULL,
"approvalThresholdBps" INTEGER NOT NULL,
"votingWindowHours" INTEGER NOT NULL,
"approveCount" INTEGER NOT NULL,
"rejectCount" INTEGER NOT NULL,
"needsRevisionCount" INTEGER NOT NULL,
"alternateTimelineCount" INTEGER NOT NULL,
"totalVotes" INTEGER NOT NULL,
"approvalNumerator" INTEGER NOT NULL,
"approvalDenominator" INTEGER NOT NULL,
"approvalPercentageBps" INTEGER NOT NULL,
"aiWarningAcknowledged" BOOLEAN NOT NULL DEFAULT false,
"aiWarningSummary" TEXT,
"baseCanonVersionId" TEXT,
"branchParentProposalId" TEXT,
"branchBaseLoreEntryId" TEXT,
"branchLabel" TEXT,
"conflictMetadata" JSONB,
"staleBaseAtDecision" BOOLEAN NOT NULL DEFAULT false,
"contentHash" TEXT NOT NULL,
"decisionPayload" JSONB NOT NULL,
"decisionPayloadHash" TEXT NOT NULL,
"customJsonId" TEXT,
"expectedSigner" TEXT,
"hiveEventId" TEXT,
"transactionId" TEXT,
"blockNumber" BIGINT,
"operationIndex" INTEGER,
"blockchainTimestamp" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProposalDecision_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "ProposalDecision_proposalId_key" ON "ProposalDecision"("proposalId");
CREATE UNIQUE INDEX "ProposalDecision_decisionPayloadHash_key" ON "ProposalDecision"("decisionPayloadHash");
CREATE UNIQUE INDEX "ProposalDecision_hiveEventId_key" ON "ProposalDecision"("hiveEventId");
CREATE UNIQUE INDEX "ProposalDecision_transactionId_operationIndex_key" ON "ProposalDecision"("transactionId", "operationIndex");
CREATE INDEX "ProposalDecision_outcome_idx" ON "ProposalDecision"("outcome");
CREATE INDEX "ProposalDecision_decidedAt_idx" ON "ProposalDecision"("decidedAt");
CREATE INDEX "ProposalDecision_customJsonId_idx" ON "ProposalDecision"("customJsonId");
CREATE INDEX "ProposalDecision_baseCanonVersionId_idx" ON "ProposalDecision"("baseCanonVersionId");
CREATE INDEX "ProposalDecision_branchBaseLoreEntryId_idx" ON "ProposalDecision"("branchBaseLoreEntryId");
CREATE INDEX "Proposal_votingEndsAt_idx" ON "Proposal"("votingEndsAt");
CREATE INDEX "Proposal_baseCanonVersionId_idx" ON "Proposal"("baseCanonVersionId");
CREATE INDEX "Proposal_branchBaseLoreEntryId_idx" ON "Proposal"("branchBaseLoreEntryId");

-- AddForeignKey
ALTER TABLE "ProposalDecision" ADD CONSTRAINT "ProposalDecision_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ProposalDecision" ADD CONSTRAINT "ProposalDecision_hiveEventId_fkey" FOREIGN KEY ("hiveEventId") REFERENCES "HiveEvent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Proposal discussion is mutable, off-chain application state. It never determines canon.
ALTER TYPE "ModerationTargetType" ADD VALUE 'PROPOSAL_COMMENT';

CREATE TABLE "ProposalComment" (
"id" TEXT NOT NULL,
"proposalId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"body" VARCHAR(3000) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP(3),
"deletedById" TEXT,

CONSTRAINT "ProposalComment_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "ProposalComment_proposalId_createdAt_id_idx" ON "ProposalComment"("proposalId", "createdAt", "id");
CREATE INDEX "ProposalComment_authorId_createdAt_idx" ON "ProposalComment"("authorId", "createdAt");
CREATE INDEX "ProposalComment_deletedById_idx" ON "ProposalComment"("deletedById");

ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProposalComment" ADD CONSTRAINT "ProposalComment_body_not_empty" CHECK (length(btrim("body")) > 0);
Loading
Loading