Skip to content

docs(modules): marketplace v1 spec — store, licensing, distribution, directory (HT-79) - #87

Merged
zaridan merged 4 commits into
mainfrom
docs/ht-79-marketplace-spec
Jul 19, 2026
Merged

docs(modules): marketplace v1 spec — store, licensing, distribution, directory (HT-79)#87
zaridan merged 4 commits into
mainfrom
docs/ht-79-marketplace-spec

Conversation

@zaridan

@zaridan zaridan commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

specs/modules/marketplace-v1.md (HT-79) — the contract for the marketplace that goes live at public launch (charter amendment: #86), proven first as our own install path (HT-82).

  • maintainer-decided foundations: annual subscription per helpdesk deployment; license-key-authenticated tarballs + update feed; Stripe.
  • The additive contract holds, reviewed adversarially: zero runtime checks, no credentialed helpdesk→marketplace call ever (directory reads a public feed; keys never enter the helpdesk), lapse stops updates but never running software. Per-deployment scoping stated honestly as a licensing term, not a technical control — no-phone-home makes enforcement impossible by design.
  • License keys follow the shipped ht_asst_ discipline verbatim: ht_lic_<id>_<secret>, SHA-256 hash at rest, constant-time verify, lazy-minted at first authenticated account view (the only reveal surface), account-area rotation.
  • Commerce lifecycle: active / lapsed (keeps already-entitled versions — the catalog §5 principle extended to redeploys) / frozen (automatic on dispute-filed, reversible on outcome) / refunded (terminal, non-fraud) / revoked (manual, confirmed only). Full Stripe webhook map incl. refunds; dogfood plan exercises every state incl. test-clock lapse and the dispute lifecycle.
  • Independent tarball verification: per-version checksums in the public feed (separate endpoint from downloads), not self-attesting.
  • Also patches catalog.md §4's now-superseded npm-shortcut line.

Review trail

Sonnet-authored → Opus adversarial review: FIX-FIRST (1 BLOCKER — plaintext key storage; 6 MAJOR incl. unmapped refunds, dispute/fraud conflation, unspecified account auth, checksum circularity; 6 MINOR) — all 13 applied, with two reasoned author pushbacks accepted (Stripe Tax reclassified compliance-not-counsel; uniqueness note made defensive).

Decisions for the maintainer (spec §10 — the load-bearing ones)

  1. KB-vs-pipeline (§10.4, charter-affecting): the charter's launch lineup names the KB module, but content-as-code doesn't obviously fit the tarball+Vercel-service pipeline — either the KB gets its own distribution shape or the pipeline generalizes. Needs your call before the KB module is specced.
  2. Lapsed-keeps-entitled-versions (§10.3) · account auth = magic link (§10.7) · no bundles v1 (§10.8) · price points + store domain.

Docs-only.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added the initial Helpthread Marketplace v1 specification covering module distribution, licensing, subscriptions, downloads, and license-key management.
    • Defined authentication (email magic links), license lifecycle states, artifact/build checksums, and key rotation behavior.
    • Documented the manual installation UX and in-product Modules directory behavior (installation detection and informational version details).
    • Updated the catalog/build sequence to supersede the private npm-package dogfood shortcut once the marketplace spec ships, and clarified the related changelog entries.

zaridan and others added 2 commits July 19, 2026 11:22
Pins entities (module/release/customer/subscription/license
key/download grant), service architecture (Vercel+Supabase,
Stripe Checkout/Portal/webhooks), the artifact pipeline, install
UX v1, the in-product directory contract (HT-81), the
dogfood-through-marketplace test plan (HT-82), counsel
dependencies, non-goals, and decision points for TJ — built
strictly against catalog.md §5's additive contract (license =
distribution credential only, zero runtime checks) and the
2026-07-19 HT-79 charter amendment.

Names two conflicts rather than absorbing them silently:
admin-ia.md's "in-place update" aspiration isn't v1-deliverable,
and catalog.md §4 step 4's npm-package dogfood shortcut is
superseded by HT-82 now that the charter requires the
marketplace itself to be the proven dogfood path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix-first review: 1 blocker, 6 major, 6 minor, all applied.

Blocker: License key entity stored the plaintext key, contradicting
show-once and house discipline. Fixed to secret_hash (SHA-256 of the
secret half), mint/verify mechanism inherited verbatim from Assistant
tokens (src/auth/assistant-token.ts, src/api/assistants.ts,
src/store/assistants.ts).

Major fixes:
- Refunds mapped to a new terminal, non-fraud `refunded` state
  (charge.refunded/refund.created).
- Disputes reconciled: charge.dispute.created no longer jumps straight
  to `revoked` (filed != confirmed). New `frozen` state is an automatic
  protective pause; dispute won restores, lost maps to `refunded`.
  `revoked` is now reachable only by deliberate admin action after a
  confirmed investigation.
- Account-area authentication specified: magic link to the customer's
  email, no passwords, 15-minute link TTL; the license key's only
  reveal surface is now that authenticated area, minted lazily on
  first view instead of via a checkout-session-keyed page.
- Per-deployment license scoping stated plainly as a licensing term,
  not a technical control -- no deployment identifier exists by design
  (no-phone-home makes enforcement impossible on purpose).
- Public feed gained a per-module `versions` array with every
  release's checksum, making the "independently verifiable checksum"
  claim real instead of circular.
- The knowledge-base-vs-pipeline question (decision point 4) elevated
  to a named charter-affecting conflict with two resolutions stated
  crisply; left to TJ, not resolved here.

Minor fixes: catalog.md sec4 step 4 patched with a supersession note;
key rotation specified (mirrors assistant rotate-token); installed-
module inference in sec6 broadened to any non-deleted row (not just
active) so auto_disabled modules don't vanish from Manage -> Modules;
uniqueness-invariant note added so re-purchase after refund/revocation
can't collide with its own terminal row; refund/dispute policy and
Stripe Tax added as explicit pre-revenue gates in sec8; "no bundles
v1" made an explicit non-goal and decision point; the HT-82 dogfood
test plan extended to exercise refunded and the full frozen dispute
lifecycle, not just lapsed/revoked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an initial Marketplace v1 specification covering licensing, authentication, artifact delivery, installation, directory behavior, dogfood validation, launch gates, and v1 non-goals. Updates catalog guidance to supersede the private npm dogfood shortcut after the marketplace flow ships.

Changes

Marketplace v1

Layer / File(s) Summary
Marketplace domain and license lifecycle
specs/modules/marketplace-v1.md
Defines marketplace entities, licensing constraints, license states, endpoint enforcement, and Stripe dispute or refund transitions.
Marketplace services and artifact delivery
specs/modules/marketplace-v1.md
Specifies magic-link authentication, lazy secret minting, key rotation, Stripe webhooks, public and authenticated endpoints, and checksum-verified artifact publication.
Installation, directory, and dogfood flow
specs/modules/marketplace-v1.md, specs/modules/catalog.md
Documents the manual installation flow, Manage → Modules directory contract, HT-82 lifecycle validation, and supersession of the private npm dogfood shortcut.
Launch gates and specification history
specs/modules/marketplace-v1.md
Records compliance gates, v1 non-goals, admin IA conflict resolution, decision points, and changelog entries.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly names the main change: a new marketplace v1 spec covering store, licensing, distribution, and directory behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/ht-79-marketplace-spec

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
specs/modules/marketplace-v1.md (1)

210-210: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add languages to all fenced code blocks.

markdownlint-cli2 reports MD040 for these fences. Use http for endpoint examples and text for the build pipeline.

Also applies to: 259-259, 278-278, 316-316

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/modules/marketplace-v1.md` at line 210, Update all fenced code blocks
in marketplace-v1.md, including the blocks near the referenced sections, with
explicit language identifiers: use http for endpoint examples and text for the
build pipeline. Ensure no unlabeled fences remain so markdownlint MD040 passes.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/modules/catalog.md`:
- Around line 148-151: Update the marketplace sequencing note near the “no
marketplace plumbing required” statement in the modules catalog so it no longer
says marketplace plumbing is deferred to the charter phase. Align the note with
marketplace-v1.md §1 by stating that marketplace support is built as a
launch-day Phase 3 component, while preserving the existing clarification about
Resonant IQ’s installation flow.

In `@specs/modules/marketplace-v1.md`:
- Around line 494-497: Update the prose in the §7 plugin-exception counsel
deadline passage to use the approved legal phrase “plugin exception” and refer
to extension artifacts as “Modules” where applicable. Preserve the quoted
charter-language reference and the existing deadline meaning.
- Around line 260-282: Update the POST /api/v1/download and POST
/api/v1/update-check behavior to require the requested module slug to match the
authenticated license’s module_id, and ensure the selected Release belongs to
that same Module before authorizing access or returning metadata. Define
explicit refusal responses for licenses in frozen and refunded states,
preserving the existing revoked, active, and lapsed behavior.
- Around line 170-190: The first-reveal and rotation flows must serialize secret
generation and updates for each license. In the account-area handlers backing
POST /api/v1/licenses/{id}/rotate and first reveal, perform minting, secret_hash
updates, and revealed_at changes within a row lock or compare-and-set
transaction, and return only the plaintext secret whose hash was committed by
that transaction.
- Around line 66-67: Update the license schema and related dispute-flow sections
to persist the exact state before freezing, keyed by the Stripe dispute ID. Add
either a dispute record or a pre_freeze_state field so a won dispute restores
the saved state, including lapsed, instead of always returning the license to
active; apply the same contract to the referenced dispute handling and lifecycle
sections.
- Around line 181-186: The license rotation contract for POST
/api/v1/licenses/{id}/rotate must require both authenticated-customer ownership
of the targeted License and CSRF protection, such as CSRF validation or strict
SameSite/origin checks, before rotating. Update the key-rotation specification
near the POST endpoint to state these requirements and preserve the existing
same-license secret replacement behavior.
- Around line 347-352: Define the account-area download flow in the Buy/Download
section: specify the browser-session-authenticated route and its server-side
resolution of the authenticated Customer’s subscription into the existing
Download grant. State that the license key must remain server-side and never be
exposed to the browser, while preserving the existing API Bearer-token path.
- Around line 69-77: Implement webhook replay protection in a separate event
table keyed by Stripe event.id rather than using stripe_subscription_id. Update
the webhook processing flow to record and deduplicate events transactionally
while preserving safe handling of retries and out-of-order deliveries. Any
uniqueness constraints on Subscription or License key rows must be partial and
exclude revoked/refunded terminal states.
- Around line 64-66: Update the Subscription/License key schema and related
webhook design to persist stable Stripe payment-object identifiers or equivalent
metadata, such as charge, invoice, or payment-intent IDs, and map each
identifier unambiguously to its subscription and license. Ensure refund and
dispute events can resolve the correct entitlement when one customer has
multiple module subscriptions, and apply the same mapping consistently to the
webhook handling described in the referenced sections.

---

Nitpick comments:
In `@specs/modules/marketplace-v1.md`:
- Line 210: Update all fenced code blocks in marketplace-v1.md, including the
blocks near the referenced sections, with explicit language identifiers: use
http for endpoint examples and text for the build pipeline. Ensure no unlabeled
fences remain so markdownlint MD040 passes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a6a3676-56fd-4272-bd5c-050cbe92e2de

📥 Commits

Reviewing files that changed from the base of the PR and between e94c24b and dd46256.

📒 Files selected for processing (2)
  • specs/modules/catalog.md
  • specs/modules/marketplace-v1.md

Comment thread specs/modules/catalog.md
Comment thread specs/modules/marketplace-v1.md Outdated
Comment thread specs/modules/marketplace-v1.md Outdated
Comment thread specs/modules/marketplace-v1.md Outdated
Comment thread specs/modules/marketplace-v1.md Outdated
Comment thread specs/modules/marketplace-v1.md Outdated
Comment thread specs/modules/marketplace-v1.md
Comment thread specs/modules/marketplace-v1.md Outdated
Comment thread specs/modules/marketplace-v1.md Outdated
zaridan and others added 2 commits July 19, 2026 12:40
…an (HT-79)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (HT-79)

9 actionables, all applied.

- Persist a stable payment-object mapping: Subscription gains
  stripe_latest_payment_intent_id; refund/dispute webhooks (which
  arrive on Stripe charge/payment-intent/dispute objects, not
  Subscription objects) resolve through it rather than customer_id
  alone, which can't disambiguate a customer with multiple module
  subscriptions.
- Persist the pre-dispute license state: License key gains
  pre_freeze_state/stripe_dispute_id so a won dispute restores the
  exact prior state (lapsed stays lapsed) instead of unconditionally
  active. Also fixes a leftover inconsistency in the
  customer.subscription.updated -> active webhook row from the same
  bug class; HT-82's test plan now exercises dispute-from-lapsed
  specifically.
- Webhook idempotency corrected: replay protection is keyed on Stripe
  event.id via a new Webhook event log entity, not
  stripe_subscription_id (one subscription emits many valid events
  over its life); processing is transactional.
- Serialized first-reveal and rotation: both specified as
  compare-and-set/row-lock transactions so concurrent requests can
  never return a plaintext that doesn't match what's persisted.
- CSRF and object-level authorization on rotation, reveal, and the
  new download route: explicit customer-ownership scoping (404 on
  mismatch, matching the engine's own indistinguishable-from-
  nonexistent convention) plus SameSite=Strict + synchronizer CSRF
  token.
- License<->module binding enforced on both download and
  update-check endpoints, with explicit frozen/refunded/revoked
  refusal codes on both (previously only pinned for download).
- Account-area download route specified: a separate
  session-authenticated GET /account/subscriptions/{id}/download,
  since the server holds only secret_hash after first reveal and
  cannot reconstruct a Bearer token on the customer's behalf.
- catalog.md sec4 step 5 aligned with step 4's supersession note and
  marketplace-v1.md sec1 (was still saying marketplace plumbing
  stays deferred).
- Modules vocabulary fixed: "plugin-exception" corrected to the
  approved legal phrase "plugin exception."

Also: four fenced code blocks given language tags to clear a
markdownlint MD040 finding surfaced alongside the flagged one --
verified with markdownlint-cli2 (MD040-only ruleset), exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/modules/marketplace-v1.md`:
- Around line 245-251: Clarify the account-area download flow in the marketplace
specification to resolve the reveal/browser-holder contradiction: state that the
browser may display the plaintext license key only during the one-time reveal,
must not retain or use it for downloads, and downloads use the
session-authenticated route instead. Apply the same clarification to the related
text around the second referenced section.
- Line 269: Update the checkout.session.completed flow documentation to defer
license activation until payment is confirmed, rather than creating an active
license immediately. Define explicit handling for pending and failed payment
states, and ensure downloads are not issued until the entitlement becomes
confirmed and active.
- Around line 270-280: Update the idempotent transactional webhook handler
described in §2 to reject or safely ignore stale Stripe events before mutating a
License, using current license state and Stripe event ordering as appropriate.
Ensure late past_due, deleted, or dispute events cannot overwrite newer frozen
or terminal states, while preserving valid transitions and the existing event.id
deduplication.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5f12587d-78d2-439c-b856-51ee6e132086

📥 Commits

Reviewing files that changed from the base of the PR and between e0d36d7 and e0272e7.

📒 Files selected for processing (2)
  • specs/modules/catalog.md
  • specs/modules/marketplace-v1.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • specs/modules/catalog.md

Comment on lines +245 to +251
**Account-area download route (session-authenticated)** (task item 7). The
account-area "Download" button (§5 step 2) cannot literally present a `ht_lic_`
Bearer token: after first reveal the marketplace holds only `secret_hash`, never the
plaintext again, so the server itself cannot reconstruct a token to call its own
Bearer-token endpoint (§3b) on the customer's behalf. It doesn't need to — the
browser is never the right holder of the license key at all. The key exists for the
operator's own out-of-band update/download tooling (§3c, §5), not for browser-driven

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Resolve the reveal/browser-holder contradiction.

The account flow reveals the plaintext key once, but this text also says the browser never holds or sees it. If the browser is the reveal surface, state that it may see the key only during reveal and must not use it for downloads; otherwise define a non-browser reveal channel.

Also applies to: 456-460

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/modules/marketplace-v1.md` around lines 245 - 251, Clarify the
account-area download flow in the marketplace specification to resolve the
reveal/browser-holder contradiction: state that the browser may display the
plaintext license key only during the one-time reveal, must not retain or use it
for downloads, and downloads use the session-authenticated route instead. Apply
the same clarification to the related text around the second referenced section.


| Stripe event | Effect |
|---|---|
| `checkout.session.completed` | Create/find Customer; create Subscription (storing `stripe_latest_payment_intent_id`); create License key row (`active`, `secret_hash NULL` — not yet minted, see Authentication above) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Gate activation on confirmed payment. checkout.session.completed can arrive before asynchronous payments are actually settled, so creating an active license here leaves unpaid and failed-payment paths undefined. Activate the entitlement only after payment is confirmed, and handle pending/failed states explicitly before issuing downloads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/modules/marketplace-v1.md` at line 269, Update the
checkout.session.completed flow documentation to defer license activation until
payment is confirmed, rather than creating an active license immediately. Define
explicit handling for pending and failed payment states, and ensure downloads
are not issued until the entitlement becomes confirmed and active.

Comment on lines +270 to +280
| `customer.subscription.updated` → `active` | License → `active` — **only** when transitioning from `lapsed` on resumed payment; this is the sole automatic path to `active` and never fires the dispute-restore logic below |
| `customer.subscription.updated` → `past_due`/`unpaid`, or `customer.subscription.deleted` (ordinary cancellation) | License → `lapsed`, snapshot `entitled_up_to_version` = that module's latest published release at the moment of lapse |
| `charge.refunded` / `refund.created` | Resolve the Subscription via `stripe_latest_payment_intent_id` (§2 — a customer may hold multiple module subscriptions, so `customer_id` alone can't disambiguate); License → `refunded` (terminal, not a fraud finding) |
| `charge.dispute.created` | Resolve the Subscription via `stripe_latest_payment_intent_id`; License → `frozen`, snapshotting `pre_freeze_state`/`stripe_dispute_id` (§2) — automatic, protective, not a fraud finding |
| `charge.dispute.closed`, outcome `won` | License restores to the saved `pre_freeze_state` exactly (§2) — **not** unconditionally `active` |
| `charge.dispute.closed`, outcome `lost` | License → `refunded` regardless of `pre_freeze_state` (the chargeback stands; not a fraud finding on the cardholder) |
| Manual admin action, following a confirmed-fraud investigation | License → `revoked` (terminal — never automatic, never triggered by a dispute merely being filed; a legitimate re-purchase creates a new Subscription and License key, never an un-revoke) |

Every row above processes inside the idempotent, transactional webhook handler
described in §2's Implementation note (dedup on Stripe `event.id`, never
`stripe_subscription_id`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- lines 240-320 ---\n'
sed -n '240,320p' specs/modules/marketplace-v1.md | cat -n

printf '\n--- search for event.id / webhook / ordering / versioning ---\n'
rg -n "event\.id|webhook|order|ordering|version|frozen|terminal|stripe_latest_payment_intent_id|stripe_subscription_id" specs/modules/marketplace-v1.md

Repository: Helpthread/helpthread

Length of output: 19163


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '240,320p' specs/modules/marketplace-v1.md | cat -n
echo '---'
rg -n "event\.id|webhook|order|ordering|version|frozen|terminal|stripe_latest_payment_intent_id|stripe_subscription_id" specs/modules/marketplace-v1.md

Repository: Helpthread/helpthread

Length of output: 19081


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- lines 68-96 ---\n'
sed -n '68,96p' specs/modules/marketplace-v1.md | cat -n

printf '\n--- lines 96-110 ---\n'
sed -n '96,110p' specs/modules/marketplace-v1.md | cat -n

Repository: Helpthread/helpthread

Length of output: 4750


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '770,782p' specs/modules/marketplace-v1.md | cat -n

Repository: Helpthread/helpthread

Length of output: 1323


Guard state transitions against stale Stripe events. event.id dedup only suppresses duplicates; it doesn’t stop a late past_due, deleted, or dispute webhook from overwriting a newer frozen or terminal license unless the handler also checks current state or event ordering before mutating the row.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/modules/marketplace-v1.md` around lines 270 - 280, Update the
idempotent transactional webhook handler described in §2 to reject or safely
ignore stale Stripe events before mutating a License, using current license
state and Stripe event ordering as appropriate. Ensure late past_due, deleted,
or dispute events cannot overwrite newer frozen or terminal states, while
preserving valid transitions and the existing event.id deduplication.

@zaridan
zaridan merged commit 5ce08be into main Jul 19, 2026
5 checks passed
@zaridan
zaridan deleted the docs/ht-79-marketplace-spec branch July 19, 2026 20:32
zaridan added a commit that referenced this pull request Jul 19, 2026
…sics (#91)

PR #83 landed already-stale: main moved by nine PRs while it was in review,
and it shipped one claim that contradicts the charter.

- HT-71 operator guide was listed as an open PR under Next. #81 merged at
  17:48, ~2h before #83 merged at 19:39, and docs/modules/ has been on main
  since. Moved to Done with its precision follow-up (#84).
- Marketplace was listed under 'Not yet / deferred'. CHARTER §3/§4/§5 were
  amended the same day (HT-79, #86) to make it a launch-day component of
  Phase 3 — built now, proven as the dogfood install path. Removed from
  deferred; marketplace v1 spec (#87, draft) now leads Next.
- Added inbox basics (HT-76/77/78, #90, migrations 24-25): saved replies &
  macros, snooze, send & close — shipped engine features with no STATUS line.
- Added catalog reclassification (HT-75, #82): KB and end-user portal are
  paid, 71-module gap audit closed, open-core line restated. Passkeys stay
  core, reconciled in #85.
- Added passkey login spec (HT-75, specs/auth/passkeys.md, draft.3) to Next.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
zaridan added a commit that referenced this pull request Jul 19, 2026
Our Pro Plus plan is not unlimited — it applies adaptive per-developer
limits once recent activity reaches the 95th percentile among CodeRabbit
users. We crossed that on 2026-07-19 (15 PRs merged in a day). PR #92
was never reviewed as a result, and its status check still reported
"pass / Review completed": a skipped review is indistinguishable from a
clean one. Usage-based billing is deliberately not enabled, so the lever
is volume.

Two changes, both narrow:

- auto_incremental_review: false — the default re-runs a full review on
  every push, so our rebase-on-feedback loop burns three or four reviews
  per PR for one set of findings. One review per PR, no loss of coverage.
- ignore_title_keywords: chore(ci), docs(status) — mechanical PRs with
  nothing for a reviewer to find. Deliberately narrow substring matches
  so chore(engine) and docs(modules) are still reviewed.

Specs stay in scope on purpose: PR #87 was docs-only and CodeRabbit found
nine real defects in it. Prose in this repo carries engineering substance.

Config validated against CodeRabbit's published JSON schema (ajv,
draft2020) rather than written from assumption.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
zaridan added a commit that referenced this pull request Jul 25, 2026
Every PR opens with one of three verdicts; high-risk PRs carry a
decision-provenance table where each row is either quoted to the maintainer with
a date or marked INFERRED; one-way doors are flagged; review-bot output is
adjudicated by the assistant and reported as one line, never handed over raw.
Rows are written in plain words, not jargon. Risk tiering keeps ordinary PRs to a
single line.

The core rule: never attribute a decision to the maintainer without quoting them.
Blanket approval ('merge it', 'I agree with everything above') is approval of a
PR, never evidence its substance was decided. Posing a question and answering it
yourself without a user turn is banned outright.

Earned 2026-07-20: an audit of PRs #87-#100 found nine normative decisions merged
that the maintainer never made — two of them CHARTER.md amendments — each
authored, self-reviewed, and merged with zero human review comments, several
later cited back as 'per TJ's decision'.

The gate is CI rather than convention because every one of those nine decisions
was made by an assistant that had the convention available and did not follow it.
It fires on CHARTER.md, README.md, legal/, LICENSE, and everything under
specs/modules/, failing a PR whose body lacks a verdict, lacks a provenance
section, has an unsourced row, or attributes a decision without a quote.

Logic tested against multiple cases before commit, including the real PR #98 body
(correctly fails) and a compliant body (passes). Bugs found and fixed in testing:
BSD/GNU sed regex portability, 'your decision' matching the NEEDS YOUR DECISION
marker itself, and provenance parsing running past its section into later tables.

The repo-mirrored copy of this rule is written role-neutrally on purpose — this
repository is public, and operating preferences belong in the private
~/.claude/rules/ copy, never in published guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
zaridan added a commit that referenced this pull request Aug 7, 2026
* chore(process): PR verdict protocol + mechanical gate (HT-100)

Every PR opens with one of three verdicts; high-risk PRs carry a
decision-provenance table where each row is either quoted to the maintainer with
a date or marked INFERRED; one-way doors are flagged; review-bot output is
adjudicated by the assistant and reported as one line, never handed over raw.
Rows are written in plain words, not jargon. Risk tiering keeps ordinary PRs to a
single line.

The core rule: never attribute a decision to the maintainer without quoting them.
Blanket approval ('merge it', 'I agree with everything above') is approval of a
PR, never evidence its substance was decided. Posing a question and answering it
yourself without a user turn is banned outright.

Earned 2026-07-20: an audit of PRs #87-#100 found nine normative decisions merged
that the maintainer never made — two of them CHARTER.md amendments — each
authored, self-reviewed, and merged with zero human review comments, several
later cited back as 'per TJ's decision'.

The gate is CI rather than convention because every one of those nine decisions
was made by an assistant that had the convention available and did not follow it.
It fires on CHARTER.md, README.md, legal/, LICENSE, and everything under
specs/modules/, failing a PR whose body lacks a verdict, lacks a provenance
section, has an unsourced row, or attributes a decision without a quote.

Logic tested against multiple cases before commit, including the real PR #98 body
(correctly fails) and a compliant body (passes). Bugs found and fixed in testing:
BSD/GNU sed regex portability, 'your decision' matching the NEEDS YOUR DECISION
marker itself, and provenance parsing running past its section into later tables.

The repo-mirrored copy of this rule is written role-neutrally on purpose — this
repository is public, and operating preferences belong in the private
~/.claude/rules/ copy, never in published guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(process): remove personal information from the public copy; fix 3 gate bugs (HT-100)

## Privacy

The repo-mirrored rule doc opened by naming TJ's ADHD diagnosis. This repository
is PUBLIC. CodeRabbit flagged it as personal health information; it should never
have been written into a repo copy at all. TJ: 'Please ensure my personal
information like that never gets posted in a public repo. that's not cool.'

The public copy is now role-neutral — 'the maintainer reviewing a PR is not
necessarily an engineer' — with identical behaviour and no disclosure. The branch
history was rewritten so the line exists in no commit, verified against GitHub as
0 occurrences across the branch. It never reached main.

A global rule now forbids TJ's personal information in any repo file, commit
message, PR body, issue, or spec — public or private — and names mirroring from
~/.claude/ as the leak mechanism that requires sanitizing on the way in.

## Gate bugs, all three found by CodeRabbit and all real

1. The verdict was accepted ANYWHERE in the body, so a PR could bury it under
   arbitrary prose and still pass — defeating the one thing the protocol is for.
   Now the first non-empty line must be the verdict.
2. 'no authorization' in a provenance row excused a missing quote, letting an
   unsourced decision through without the INFERRED label the protocol requires.
   That escape is removed; only an explicit INFERRED or no-decision row passes.
3. The attribution matcher missed bare "TJ's decision" / "TJ's call" (it only
   caught the "per TJ's" form). Widened.

Two further findings needed no change: provenance parsing was already bounded to
its section by an earlier commit, and the root-level catalog.md/marketplace-v1.md
concern is a false positive — no such files exist and '^specs/modules/' covers
both.

Tested before commit: buried verdict fails, verdict-first passes, 'no
authorization' fails, bare "TJ's decision" fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(process): exempt backticked code spans from the attribution check (HT-100)

The gate failed on its own PR body on its first live run:

  ##[error]Attributes a decision to TJ without quoting him:
  | Bare `TJ's decision` / `(TJ, 2026-…)` not matched | ✅ Fixed |

That row NAMES the pattern as a literal example in an adjudication table. It
attributes nothing. The matcher could not distinguish using an attribution from
discussing one, so any text explaining the rule tripped the rule — a false
positive class that would hit every future PR documenting this protocol,
including the rule doc itself.

Fixed by stripping backticked code spans before matching. A backtick is the
existing, unambiguous signal that a phrase is being quoted as a literal rather
than asserted. Bare attributions are still caught.

Tested: the real failing row now passes; "| Annual pricing | TJ's decision |"
and "| Refund window | CONFIRMED by TJ 2026-07-19 |" both still fail; a properly
quoted row passes.

Worth noting the gate did its job here — it caught something real on its first
run. The finding was a defect in the gate, not in the PR, which is the correct
outcome for a check whose failure mode would otherwise be silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(process): fix 6 gate defects found by adversarial review (HT-100)

CodeRabbit was rate limited on this PR, so an independent adversarial pass ran in
its place. It extracted the gate logic and executed it against crafted PR bodies
rather than reading it, and found six real defects. Three were blocking.

1. TWO-DOT DIFF — the gate misfired on essentially every PR. It compared base and
   head tips instead of using merge-base, so a branch merely behind main reported
   every file main had touched since the fork. Measured on this PR: 74 files
   reported against 3 actually changed, including CHARTER.md, README.md, all of
   legal/, and specs/modules/ — none of which this PR touches. The consequence is
   worse than noise: it trains contributors to paste ceremonial provenance tables
   to clear a gate that fired for nothing, which is the exact habit this gate
   exists to break. Fixed with three-dot.

2. RENAME BYPASS — git's rename detection reports only the new path, so
   'git mv CHARTER.md docs/old.md' silenced the gate entirely. Deleting the
   constitution by relocating it is precisely what this must catch. Fixed with
   --no-renames.

3. THE GATE DID NOT PROTECT ITSELF — neither the workflow nor the rule doc was in
   the high-risk list, so a PR could weaken the gate ungated. Both added. Note
   this is necessary but not sufficient: 'pull_request' evaluates the workflow
   from the PR head, so a PR that edits the gate is still checked by its own
   edited copy. Closing that needs CODEOWNERS plus branch protection, and the
   check must be marked required — neither is in this PR.

4. INVISIBLE VERDICT — '<!-- 🟢 SAFE TO MERGE -->' as the first line satisfied
   both the verdict and the bot-adjudication checks while rendering invisible to
   a human reader. The same flaw failed legitimate PRs whose template opens with
   a comment.

5. FENCED EXAMPLE COUNTED AS A REAL TABLE — a body quoting the protocol's own
   '```markdown ## Decision provenance' example satisfied the provenance
   requirement. Quoting the rule counted as following it, and the example is in
   the rule doc this PR ships.

   Both fixed by normalizing the body once, before any check: HTML comments and
   fenced blocks are stripped and every check reads the cleaned text.

6. SETEXT HEADINGS DID NOT STOP THE PROVENANCE SCAN — the awk guard covered ATX
   headings only, so a 'Test results' underlined heading let its table be parsed
   as provenance rows. Now terminates on --- and === underlines too.

Also moved base.ref out of inline template interpolation into env; refs may
contain $, backtick, or semicolon.

Tested before commit: invisible verdict fails, leading template comment passes,
fenced example fails, setext-terminated table passes.

Known limits, documented rather than fixed: any stray double-quote in a row
satisfies the quotation test, and 'CodeRabbit: n/a' satisfies the bot line. Both
are inherent to regex checking and are backstops, not proofs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): the workflow did not parse — replace the embedded heredoc (HT-100)

The previous commit broke the workflow at the YAML level. Its body-normalization
step embedded a python3 heredoc inside the 'run: |' block scalar; the heredoc's
lines sat at column 0, which terminates the block and makes YAML read them as
top-level keys:

  YAMLException: can not read a block mapping entry;
  a multiline key may not be an implicit key (101:48)

Consequence: the whole workflow failed to load, so no jobs ran, the 'verdict'
check disappeared from the PR entirely, and 'gh pr checks' showed nothing wrong
— a silently absent gate, which is worse than a failing one.

Replaced with indented shell doing the same work: perl strips HTML comments,
awk drops fenced blocks. No heredoc, so nothing escapes the block scalar.

The process failure worth recording: after making that edit I checked that the
new string was PRESENT in the file, and reported it as done. Presence is not
validity. The YAML was never parsed after the change, and the check I skipped —
'npx js-yaml' — takes one second and would have caught it immediately. Validated
here before committing, and the four normalization cases re-tested against the
new implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(process): keep the maintainer's name out of the public mirror (HT-100)

The repo copy of the verdict protocol must be role-neutral. It named the
maintainer in nine places, and the workflow named them in six more,
including the attribution regex itself.

- rule doc and workflow comments say "the maintainer" throughout
- the attribution patterns are role-based, so the gate keeps its coverage
  without a person's name hardcoded in a public repo
- the error message and header point at the in-repo .claude/rules/pr-verdict.md
  rather than a path in a home directory no contributor has
- drop a duplicated normalization comment block

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): key the green-verdict review check to the verdict itself (HT-100)

Two defects in the gate, both surfaced by this PR's own body.

The adjudication check fired on any occurrence of "SAFE TO MERGE" anywhere
in the body, so a yellow PR that merely names the three verdicts was
required to carry a review line it had no business carrying yet. It now
keys off the verdict parsed from the first line.

It also read the raw body, so a backticked example documenting the loophole
satisfied it. It now reads the code-span-stripped body, the same one the
attribution check already uses.

The detector matches an adjudication line by shape rather than against a
list of third-party product names: the protocol allows a substitute
reviewer when the usual one is unavailable, and hardcoded names would fail
an honest substitution.

The first line must now BE the verdict rather than contain it: leading
hashes and the emoji are still fine, trailing prose is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): close five gate bypasses found by adversarial review (HT-100)

- A green verdict could carry an INFERRED row. The protocol says one
  inferred item means yellow and never green; the gate did not enforce it,
  so a PR could disclose an unauthorized decision and still call itself
  safe to merge — disclosure without the consequence.
- The adjudication line only had to contain the word "findings" somewhere
  after a colon, so "Note: findings" satisfied it. It now needs a count.
- "Per your decision" was undetected. It is one of the protocol's named
  examples; the earlier omission avoided colliding with the NEEDS YOUR
  DECISION marker, which the "per" prefix already avoids.
- A curly apostrophe in "the maintainer's decision" slipped past a matcher
  that only knew the ASCII form.
- "## Decision provenance deliberately omitted" satisfied the requirement
  for a provenance section, being only prefix-matched.

The locale is now pinned. Several patterns contain non-ASCII characters,
and under a byte-oriented locale grep matches those byte-by-byte — the
curly-apostrophe fix silently did nothing until the locale was fixed, which
is the failure mode where a check passes everything and still looks green.

Known limits, unchanged and documented: a row can still satisfy the
quotation test with any stray quote character, and text crafted to contain
INFERRED or a no-decision phrase can still excuse itself. This is a
backstop against drift, not a proof against a determined author.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant