{
  "id": "penny",
  "name": "Penny",
  "version": "0.0.0-bootstrap",
  "maturity": "ui-port-read-spine",
  "category": "feedback",
  "spine": {
    "id": "feedback",
    "record": "request with attributed evidence"
  },
  "replaces": [
    {
      "name": "Canny",
      "edition": null
    }
  ],
  "description": "A self-hosted customer-feedback decision system that connects attributed evidence, accountable product decisions, customer-safe roadmaps and releases, and requester outcomes for one B2B software business.",
  "capabilities": [
    {
      "id": "owner-front-door",
      "description": "Serve the owner's public feedback portal without vendor marketing or operator-only data.",
      "clauses": [
        "HOME-001"
      ]
    },
    {
      "id": "public-published-reads",
      "description": "Read published customer-safe feedback, approved roadmap projections, and published releases through explicit public DTOs. Portal identity and mutation actions are unavailable.",
      "clauses": [
        "REQ-001",
        "REQ-016",
        "ROAD-006",
        "REL-010"
      ]
    },
    {
      "id": "customer-safe-projection",
      "description": "Serve public reads from explicit customer-safe projection DTOs without account, evidence, decision, or operator-only fields.",
      "clauses": [
        "DATA-002",
        "EVID-006"
      ]
    },
    {
      "id": "operator-read-workspace",
      "description": "Read the current D1-backed evidence, requests, decisions, roadmap, releases, and accounts only through a valid allow-listed operator session. No operator mutation is available.",
      "clauses": [
        "OPS-003"
      ]
    },
    {
      "id": "local-data-and-health",
      "description": "Migrate and import the curated D1 sample idempotently through feature services, and report health only when D1, R2, and Queue bindings are reachable.",
      "clauses": [
        "BASE-OPS-001"
      ]
    }
  ],
  "nonGoals": [
    "Multiple workspaces, businesses, or tenants",
    "Granular roles, permissions, and approval chains",
    "A CRM, support inbox, survey, NPS, community chat, or general-purpose forum",
    "Anonymous or unverified voting",
    "AI classification, summarisation, scoring, prioritisation, or automatic merging",
    "A combined priority score or automatic ranking of requests, bets, or roadmap items",
    "Automated CRM, support, review-site, or call ingestion",
    "Importing feedback from another feedback tool",
    "A script-tag feedback widget or in-app capture SDK beyond the embeddable portal in REQ-027",
    "Bidirectional Jira, Linear, or other delivery-tracker synchronisation",
    "Portfolio capacity planning, Gantt charts, sprint management, and public delivery estimates",
    "Account-specific public roadmaps",
    "Native mobile applications"
  ],
  "externals": [
    {
      "id": "transactional-email",
      "required": true,
      "why": "Operator magic-link sign-in (BASE-ACCESS-001), requester email verification (REQ-023), decision-review alerts (NOTIFY-003), and opted-in requester status and release notifications must reach addresses outside the deployment; no owned Cloudflare primitive delivers mail. The adapter must accept a caller-supplied idempotency key, enforced at the provider, so REL-006 can retry an ambiguous send without duplicating it, and must deliver signed delivery, bounce, and complaint callbacks so REL-007, REL-011, and REL-013 can update delivery state.",
      "data": [
        "recipient email address",
        "single-use sign-in or verification link, or customer-safe notification content",
        "delivery key for bounce and retry correlation"
      ],
      "adapters": [
        "mail.sender.v1"
      ]
    }
  ],
  "env": [
    {
      "name": "APP_ORIGIN",
      "required": true,
      "secret": false,
      "why": "Canonical origin for sign-in callbacks and public links."
    },
    {
      "name": "AUTH_SECRET",
      "required": true,
      "secret": true,
      "why": "Signs operator sessions, requester verification tokens, and one-time authentication state."
    },
    {
      "name": "MAIL_FROM",
      "required": true,
      "secret": false,
      "why": "Verified no-reply sender for sign-in, verification, and notification email (NOTIFY-004)."
    },
    {
      "name": "MAIL_PROVIDER_API_KEY",
      "required": true,
      "secret": true,
      "why": "Authenticates the default transactional-mail adapter."
    },
    {
      "name": "MAIL_WEBHOOK_SECRET",
      "required": true,
      "secret": true,
      "why": "Verifies transactional-mail delivery, bounce, and complaint callbacks."
    },
    {
      "name": "PORTAL_IDENTITY_SECRET",
      "required": false,
      "secret": true,
      "why": "Optional shared secret for host-signed requester identities (REQ-031); verified-email identity remains available without it."
    }
  ],
  "deploy": {
    "healthPath": "/health",
    "operatorPath": "/app",
    "publicPaths": [
      "/",
      "/signin",
      "/health",
      "/assets/",
      "/api/auth/session",
      "/api/public/boards",
      "/api/public/requests",
      "/api/public/requests/",
      "/api/public/roadmap",
      "/api/public/releases",
      "/api/public/releases/",
      "/portal",
      "/portal/"
    ],
    "notes": "/signin is the sessionless operator sign-in page; native magic-link issuance is not implemented in this read-only port. /api/auth/session is the session inspection endpoint: it returns an operator only for a valid allow-listed D1 session and does not issue one."
  },
  "customFields": [
    "account",
    "requester",
    "evidence",
    "request",
    "decision",
    "roadmapItem",
    "release"
  ],
  "extensionPoints": {
    "contributions": [
      "app.page.v1",
      "app.navigation.v1",
      "app.settings-section.v1",
      "job.consumer.v1",
      "cron.task.v1"
    ],
    "policies": [
      "operator.management.v1",
      "request.moderation.v1",
      "request.visibility.v1",
      "request.vote-eligibility.v1",
      "decision.commitment.v1",
      "roadmap.publication.v1",
      "release.recipient-eligibility.v1",
      "requester.notification.v1"
    ],
    "events": [
      "evidence.created.v1",
      "evidence.linked.v1",
      "request.created.v1",
      "request.moderated.v1",
      "request.published.v1",
      "request.declined.v1",
      "request.merged.v1",
      "request.unmerged.v1",
      "decision.committed.v1",
      "decision.revised.v1",
      "decision.review-due.v1",
      "roadmap.status-changed.v1",
      "roadmap.status-update-published.v1",
      "release.published.v1",
      "release.delivery-failed.v1",
      "release.outcome-recorded.v1"
    ],
    "slots": [
      "overview.after.v1",
      "evidence.list.after.v1",
      "evidence.detail.after.v1",
      "account.detail.after.v1",
      "request.detail.after.v1",
      "decision.brief.after.v1",
      "decision.alternatives.after.v1",
      "roadmap.item.after.v1",
      "release.detail.after.v1",
      "portal.request.detail.after.v1"
    ],
    "adapters": [
      "mail.sender.v1"
    ]
  },
  "limits": {
    "provenance": "All values are estimates for backend design and must be replaced by load-test results before the first release. Latency that defines a feature lives in its contract clause, not here.",
    "status": "estimated-not-tested",
    "operators": 20,
    "boards": 100,
    "tags": 500,
    "accounts": 25000,
    "requesters": 250000,
    "evidenceItems": 1000000,
    "publicRequests": 100000,
    "votes": 2000000,
    "comments": 1000000,
    "decisions": 25000,
    "roadmapItems": 25000,
    "releases": 10000,
    "recipientsPerRelease": 50000,
    "publicReadRequestsPerMinutePerIp": 120,
    "publicWriteRequestsPerMinutePerIp": 30,
    "publicWritesPerMinutePerIdentity": 20,
    "identityActionsPerMinutePerIp": 10,
    "identityVerificationsPerHourPerEmail": 5,
    "magicLinkRequestsPerHourPerEmail": 5,
    "mailEventsPerMinutePerIp": 600,
    "requestSubmissionsPerHourPerIdentity": 10,
    "uploadBytesPerFile": 10485760,
    "uploadBytesPerRequest": 26214400,
    "uploadTypes": [
      "image/png",
      "image/jpeg",
      "image/gif",
      "application/pdf"
    ]
  },
  "accessibility": {
    "level": "WCAG 2.2 AA",
    "status": "target-not-yet-verified"
  },
  "operatingCost": {
    "status": "estimate-only",
    "basis": "Workers Free is the default Cloudflare plan; Workers Paid starts at USD 5 per account-month. The current read-only port dispatches no Queue deliveries. For the future release-delivery limit, a normal Queue message at or below 64 KB is at least three operations (write, read, delete): 3,334 successful deliveries consume at least 10,002 operations and exceed Workers Free's 10,000 Queue operations per account per UTC day. A maximum 50,000-recipient release needs at least 150,000 Queue operations before retries, dead-letter handling, or messages above 64 KB, so it genuinely requires Workers Paid. Workers Free D1 includes 5 million rows read/day, 100,000 rows written/day, and 5 GB total storage; reaching a Free D1 limit makes D1 return errors until the UTC reset or an upgrade. R2 Standard includes 10 GB-month storage, 1 million Class A and 10 million Class B operations/month; using R2 requires a billing-enabled Cloudflare account even while usage remains within those included allowances. The R2 free tier does not apply to Infrequent Access storage, which also has retrieval fees and a 30-day minimum storage duration. Cron Triggers are included; this port does not currently use Durable Objects. R2 and Workers do not charge Internet egress under the documented plans. Transactional email remains a separate provider and message-volume cost.",
    "monthlyUsd": {
      "cloudflare": "Workers Free by default; Workers Paid starts at 5, then Queue, D1, R2, and Worker usage above included allowances are usage-dependent",
      "transactionalEmail": "provider and message-volume dependent"
    },
    "excluded": [
      "domain registration",
      "operator implementation time"
    ]
  }
}
