Skip to content

Spec the plugin system, the pds migration, and the analytics plugin - #18

Merged
antstanley merged 11 commits into
mainfrom
spec/plugin-system-and-analytics
Jul 27, 2026
Merged

Spec the plugin system, the pds migration, and the analytics plugin#18
antstanley merged 11 commits into
mainfrom
spec/plugin-system-and-analytics

Conversation

@antstanley

@antstanley antstanley commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Three linked change specs and the implementation plan for them. Nothing here is implemented — this is design work for review, not code.

What it proposes

  1. An internal plugin system for the CLI — a plugin SPI in blogwright-core, discovery via a package.json manifest field, generic blogwright <plugin> <action> dispatch, and blogwright plugin add|list|remove. Internal and unversioned until it has carried two features through a release cycle.
  2. Migrate blogwright-pds onto it — pds becomes a plugin architecturally while staying a default dependency, to validate the SPI against a second consumer before anything is frozen.
  3. Analytics plugin — a second CloudFront log delivery into Firehose, a record-transform Lambda, an Iceberg table in S3 Tables, and a local SvelteKit/DuckDB dashboard. Installed with blogwright plugin add analytics.

Plus the plan: 62 tasks across eight milestones, each with a done certificate. Two milestones depend on nothing in the plugin-system or pds streams and can be worked from day one.

Review history

Seven independent passes, each by an agent with no shared context — five on Opus, two on Fable. All findings raised are closed.

The last two passes are the interesting ones. A four-round remediate/review loop went 2/3/4 → 0/3/4 → 0/4/1 → 1/2/5 (blocking/important/minor) and did not converge — each round reached a layer the previous had made visible. A final pass on a different model then found a blocking defect the three Opus passes had all missed:

Task 27 removes core's secretName default while nodes.ts:925 still interpolates that field into the OIDC policy's Resource ARN. TypeScript accepts string | undefined in a template literal, so nothing fails to compile — and applyOidcRole rewrites the whole <env>-deploy document on every blogwright bootstrap. The next bootstrap writes Resource: arn:…:secret:undefined-* into a live deploy role, and the plan explicitly blessed shipping in that state.

Both models independently identified a second defect: the "additive-first" argument for moving the pds IAM grant was true of commits and false of deployed stacks. The plugin's named policy reaches a real role only when an operator runs blogwright pds bootstrap, which nothing else invokes.

Other defects found and closed across the passes: discovery that could not resolve the blogwright package at all (its exports map has no . entry); a PdsContext change that broke the post-deploy sync; a pluginBlock() helper that could not be written (TS7053); plugin state typed so it would not compile against destroyGraph; save() classified as pass-through when it closes over the site's store — the one context member that typechecks either way; and an unreachable us-east-1 signer that would have blocked the entire analytics pipeline.

Two claims deliberately left as-is

Both checked against primary sources, not defects — please don't "fix" them:

  • The "Firehose does not recommend using multiple Firehose streams to write data to the same Apache Iceberg table" sentence is verbatim from AWS's Iceberg considerations page.
  • AppendOnly is genuinely contested: that page's prose says it is settable only at CreateDeliveryStream, while the IcebergDestinationUpdate API reference accepts it. The spec treats it defensively rather than picking a side AWS has not.

A type-claim gate

These documents make type-level claims — that OpsContext fails to satisfy PluginContext with TS2739, that ctx.config.analytics is TS2339, that a constraint holds. Nothing checked them against each other, so a change to one proposed type left adjacent tasks asserting the old truth, and reviews caught it two rounds later.

type-claims/ closes that. It carries a transcription of the proposed SPI types — each citing the spec section it came from — and compiles 29 assertions against the repo's real OpsContext, OpsConfig, OpsState and PdsContext. Claims the documents say must fail are pinned with @ts-expect-error, so a claim that silently stops being true breaks the gate too.

$ node .specs/plans/2026-07-26-plugin_system_and_analytics/type-claims/check.mjs
PASS: 29 claims held (12 compiled positives, 17 pinned compile-errors)

Verified in both directions: reintroducing the constraint this PR removes makes it name the broken claim and exit 1, and removing Readonly from SiteState breaks the readonly claim on the unused directive.

When a spec changes a proposed type, update the transcription and re-run — whatever breaks names a task that needs updating.

It lives outside packages/, and knip, build, test and oxfmt are all clean with it present. It is not wired into CI; graduating it is proposed as a plan-close decision, not done here.

Verification

Graph acyclic, 115 edges, no dangling or forward edges · 62 tasks each paired with a certificate · every task's dependencies matching its plan row · all links resolving · node counts consistent at twelve.

The last review also verified ~70 file:line anchors as accurate, recompiled every load-bearing type claim against this repo's tsc, and confirmed each load-bearing AWS claim verbatim against vendor documentation.

Open questions

Seven remain, down from eleven — both that blocked implementation are resolved.

Settled in the latest commit: blogwright bootstrap now warns while a plugin's scoped state exists (reading key names only, so it costs neither the discovery-free path nor a topography leak); daily salt rotation stands, with the sum-of-daily-uniques consequence written into the dashboard's named queries; analytics backfill is a declared optional action, idempotent by construction against the Firehose path; and blogwright plugin remove asks before removing a plugin that owns resources, refusing rather than defaulting when non-interactive.

Also corrected: the record-expiration question asserted that the S3 Tables API supports a record-expiration configuration. AWS documents that it is not available for tables you createPutTableMaintenanceConfiguration governs snapshot expiry and compaction, not row retention. Aging data out means partition-level deletes the plugin issues itself. Whether to do that at all stays open; the false premise is gone.

The remaining seven are deferred by design — SPI version declaration (moot while both consumers version in lockstep), an afterDeploy hook (waiting on a second consumer), whether preview becomes a plugin, pds action aliases, the Glue catalog integration's teardown contract, row retention, and whether core should hold plugin config blocks as an opaque map.

Known residual risk

No review pass has run since the last remediation, and this corpus's history shows remediation introducing defects at roughly the rate it fixes them — the seventh pass found a blocking defect created by the fifth. The type-claim gate now catches that specific class automatically; the rest is still human-reviewable prose.

One acknowledged limitation: Readonly<Record<string, ResourceOutputs>> prevents replacing an entry in siteState.resources but not mutating inside one. The gate pins what the spec claims; a deeper guard would need a recursive mapped type.

🤖 Generated with Claude Code

antstanley and others added 11 commits July 26, 2026 17:54
The analytics feature must not ship with the CLI by default. Rather than build
a bespoke install, config and lifecycle path for one feature, these specs
introduce an internal plugin system and land analytics on top of it: install,
config ownership, writing a config block into an existing JSONC file, and
resource lifecycle become one mechanism instead of four per-feature decisions.

blogwright-pds migrates onto the SPI in the same effort, staying a default
dependency so users see no change. It exists to validate the contract against a
second consumer of genuinely different shape before anything is frozen —
designing a plugin API against one example is how the wrong abstraction happens.

Also adds the implementation plan: 57 tasks across eight milestones, with the
analytics AWS clients and transform Lambda deliberately independent of the
plugin-system track so both can be worked from day one.

Two research findings shaped the design and are recorded in the specs. Firehose
matches JSON keys to Iceberg column names exactly and drops the rest silently,
so the analytics transform Lambda is mandatory rather than an optimisation; it
is also where the viewer IP is hashed and never stored. And discovery cannot
read <package>/package.json — Node's exports encapsulation makes that throw for
every package in this workspace — so it resolves the entry point and walks up,
proven by an integration test against the real loader rather than a fake.

An adversarial review of the plan found six blocking defects, four of which
were gaps in these specs rather than in the plan. All six are repaired here;
the plan carries the record in its Verification history section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewing the change specs against the code (spec-reviewer template R3) turned
up two defects introduced by the earlier repair pass, both invisible to a
reading of the task files alone.

Task 08's discovery called ports.loader.packageJsonPathFor(), an operation task
05 never defined — a dependency on a port method no task created. Task 05 now
declares it, implements it as resolve-then-walk-up, and carries a test
contrasting it with require.resolve('<name>/package.json'), which throws
ERR_PACKAGE_PATH_NOT_EXPORTED for every package in this workspace.

Tasks 01 and 52 were repaired for the site-state/plugin-state split but their
done certificates were not, so they still encoded the single-state-field
contract. A validator would have discharged them against the wrong shape and
passed. Both certificates now name state, siteState and record() separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten findings from the adversarial review, plus two decisions the plan had
resolved silently rather than asking.

The two decisions, both settled here. The visitor_key salt is now a random
secret in Secrets Manager rather than derived from the date: IPv4 is a 2^32
space, so a salt an attacker can compute makes the digest brute-forceable in
seconds and the pseudonymisation decorative. That adds an eleventh node, a
secretsmanager:GetSecretValue grant scoped to that secret, a saltSecretName
config field and a cold-start read. And blogwright-analytics joins the fixed
changeset group, because plugin add installs it at the CLI's own version and
that version would otherwise never exist on the registry.

The structural fixes: plugin config validation moves out of createContext into
the dispatch path, which is the only place that can see the dispatched plugin
without making discovery non-lazy; core gains pluginBlock() so a plugin-owned
config key can be read without a cast, placed in task 03 rather than 27 because
27 depends on 19 and putting it there would make the graph cyclic; the transform
moves under src/ so the existing tsconfig, vitest glob and lint script cover it,
matching what build-agent actually does; and the plugin namespace dispatches
before createContext, so plugin add works on a repo that has no config yet.

The rest close correctness gaps: the static pds USAGE block now goes at task 26
rather than 29, so --help never lists pds twice mid-migration; task 57 triages
five open questions rather than four, the missing one being the destroy
interlock; and task 20 no longer claims to execute a merge-plan step it
deliberately defers.

The dependency graph is verified acyclic: 57 tasks, no dangling edges, no
forward edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec said the visitor_key salt "rotates daily" and lived in Secrets
Manager. Taken literally that means managed rotation — a rotation Lambda, a
schedule and a second execution role — which is more moving parts than the
thing they protect, and the plan had no node for any of it.

One long-lived secret with the per-day value derived as
HMAC-SHA256(secret, day) gives the same daily turnover with nothing to operate.
The stored secret is created once and never rewritten, because replacing it
after rows exist breaks unique-visitor comparison across the boundary.

Brute-force resistance is unchanged and is still the reason the salt is secret
at all: IPv4 is a 2^32 space, so a salt an attacker can compute reverses every
row to its source address in seconds.

Cost is a flat $0.40/month per environment. The transform caches the secret at
cold start — reading it per invocation would be ~43,000 GetSecretValue calls a
month at a 60-second Firehose buffer, more than half the price of the secret
for no benefit. SSM Parameter Store would be free but would cost a hand-rolled
ssm client, a bad trade against $4.80/year.

Tasks 41, 42 and 50 and their certificates now state the derived design; both
41's functions stay pure and take the secret and day as arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two places broke the rule that a plugin's resources belong to the plugin.

The site's OIDC deploy role branched on config.pds and derived that plugin's
secret ARN (nodes.ts:906) — plugin topography in the site graph. pds now
contributes its own resource node attaching a blogwright-pds-named inline
policy to the site's role (task 23), and the site drops its branch (task 58).
IamClient already has putRolePolicy/listRolePolicies/deleteRolePolicy, so no
client work. The two are sequenced additive-first: named inline policies are
separate, so both grants coexist for one step and no commit leaves a CI deploy
without access to the secret.

Four AWS clients existed in blogwright-core solely for the analytics plugin.
They move into the plugin, built over the SigningClient already on its context.
knip corroborates the placement: core would have exported four clients nothing
in core or the CLI consumes.

None of that was reachable before. ServiceKey is keyof typeof SIGNING_NAMES and
SendOptions.service is typed to it, so a plugin could not sign against a service
core did not enumerate — every new service meant editing core and moving its
published surface. Task 31 changes from "add four signing names" to "accept a
plugin-supplied service descriptor", which is what makes the rule hold for this
plugin and any future one.

Moving the clients into the analytics package made tasks 32-35 depend on the
package skeleton, which was a forward edge — the plan promises its numbers are a
valid execution order. Tasks 32-38 are rotated so the skeleton lands first.

Also resolves the ten minor review findings: plugin-commands.ts gets a named
author instead of three tasks racing to create it; task 09's collision handling
is collect-as-failure in both its Steps and its DoD rather than one of each; the
analytics package seeds a real test, because vitest exits non-zero with no test
files and would have held the repo-wide gate red for eight tasks; rolldown is
scheduled as a dependency; the plugin gets a declared composition root for its
own bundled artifact; and task 28's "if eager" branch collapses to the answer
already settled.

58 tasks, graph verified acyclic with no forward edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng, typed config

An independent semi-formal review of the three change specs found six blocking
defects. Four are applied here, plus the generic config shape that fixes two more.

Discovery could not find blogwright-pds at all. The spec scanned the consuming
repo for blogwright-* dependencies, but a consumer depends on blogwright, which
does not match the prefix — this repo's own package.json has zero blogwright-*
entries. Every bundled plugin was invisible, so deleting the hardcoded pds
branch would have broken blogwright pds entirely. The candidate set is now the
union of the consumer's deps and the CLI package's own.

The analytics spec contradicted itself: its Proposed-changes blocks put the four
AWS clients in the plugin while its Implementation notes still said to add them
to core's SIGNING_NAMES and AwsClients. The notes were stale from before the
topography change; they now describe the seams core actually gains.

Nothing could build a us-east-1 client. A SigningClient's region is fixed at
construction and the us-east-1 one is a local const reachable only through the
pre-built acm/cloudfront/route53 clients — so a plugin could not construct one,
and every analytics service is us-east-1. AwsClients gains signingUsEast1.

Analytics resource names carried no environment while every other derived name
in the repo is <env>-<siteName>. Staging and production would have shared one
Iceberg table and 'analytics destroy' in staging would have deleted
production's data. AWS gives a second reason: Firehose does not recommend two
streams writing one Iceberg table.

The shared delivery source could be torn out from under the plugin: the site's
delete() removes the source unconditionally (Conflict, throwing part-way through
destroy) and its retry deletes every delivery on the source — silently, while
the plugin's state still reports healthy. New task 52 adds both guards.

Plugin config moves to Plugin<TConfig> with validateConfig returning the
resolved block onto ctx.pluginConfig. ctx.config.analytics cannot compile —
OpsConfig has no index signature — and a validator returning void left pds'
secretName default nowhere to live, which would have widened a dozen call sites
to string | undefined.

Also corrects a claim I had asserted as researched: Firehose does not silently
discard mismatched fields. AWS documents that a column name or type mismatch
throws and delivers to the S3 error bucket; only an additional field is skipped
silently. The transform Lambda is still mandatory — only the failure mode was
wrong, and it puts the error bucket on the normal path rather than a rare one.

59 tasks, graph verified acyclic with no forward edges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rified table path

Research resolved all three, and one of them differently than expected.

The Firehose error bucket becomes a twelfth node in us-east-1 rather than
reusing the site environment bucket. S3DestinationConfiguration.BucketARN
matches an ARN pattern that carries no region, so the API can neither express
nor reject a cross-region bucket, and Firehose cross-region documentation
covers only HTTP endpoint destinations. That leaves the behaviour undocumented
rather than forbidden, which is not something to rest the pipeline on. It
matters more than it first appeared: since a schema mismatch errors every
affected record to that bucket rather than dropping it, the error path is a
normal path.

The stream is created with AppendOnly: true. page_views is insert-only by
design and Firehose scales throughput automatically for append-only Iceberg
streams. The flag is settable only at CreateDeliveryStream, so a stream created
without it has to be deleted and recreated.

The table-creation question resolved rather than blocking. Firehose documents
that it only delivers to tables created through the Iceberg GlueCatalog API,
which read as though it forbade the S3 Tables control-plane path the plan uses.
AWS own S3 Tables and Firehose walkthrough creates the namespace and table with
aws s3tables create-namespace and create-table and then points the stream at
them, so the limitation applies to plain Iceberg-on-S3 tables registered in
Glue, not to S3 Tables reached through the s3tablescatalog federation. Recorded
in task 48 so the next reader does not re-litigate it; resource links, required
until 2025-07-31, are also no longer needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four alternating rounds of clean remediation and clean review, each agent
independent with no shared context. The blocking/important/minor trajectory was
2/3/4, then 0/3/4, then 0/4/1, then 1/2/5.

It did not converge, and the shape of that matters more than the counts. Round
1's blocking findings were surface contradictions — the plan still carrying the
superseded "clients in core" design in seven places, a PluginContext whose
enumerated fields omitted names and accountId that both consumers need. What
round 4 found is a layer deeper and was only reachable once those were cleared:
the additive-first argument for moving the pds IAM grant reasons about commit
ordering, but the operative event is a deployed bootstrap, and there the two
grants never coexist. applyOidcRole rewrites the same <env>-deploy inline policy
name on every reconcile, so the first bootstrap after upgrade drops the
secretsmanager statement whether or not the plugin's own policy exists yet.

Closed across the four rounds: discovery could not resolve the blogwright
package to read its own dependencies, because the CLI's exports map has no "."
entry — so the union-with-bundled-plugins fix from the previous round was
unimplementable as written, and self-location has to go through import.meta.url.
PdsContext requiring pluginConfig broke the post-deploy sync, which reaches
requirePdsConfig from an OpsContext with no dispatch boundary to populate it.
pluginBlock could not be written at all, since indexing OpsConfig by a string is
TS7053. Plugin state was typed as a bare outputs map, which does not compile
against destroyGraph's ctx.state.resources. Plugin<TConfig> had no default, so a
plugin owning no config key had no valid type argument.

Two claims were checked against primary sources and deliberately not "fixed":
the multiple-streams-per-Iceberg-table sentence is verbatim from AWS's Firehose
considerations page, and AppendOnly is contested between that page's prose and
the IcebergDestinationUpdate API reference, so the stream node reconciles it and
falls back to replacement rather than asserting either reading.

Eight findings remain open and are listed in the pull request: one blocking, two
important, five minor. Nothing here is implemented, so they are review items
rather than defects in shipped behaviour.

59 tasks, twelve analytics nodes. Graph verified acyclic with no dangling or
forward edges, every task paired with a certificate, task dependencies matching
their plan rows, all links resolving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fifth review, on a different model, found a blocking defect three earlier
passes had missed, and independently rediscovered the deployment-ordering gap a
fourth had found. Both are fixed here, along with the remaining twelve open
findings and one the remediation turned up on its own.

The blocking defect was a release window writing a broken grant into a live
role. Task 27 removes core's secretName default while nodes.ts:925 still
interpolates that field into the OIDC policy's Resource ARN, and TypeScript
accepts string | undefined inside a template literal, so nothing fails to
compile. applyOidcRole rewrites the whole <env>-deploy document on every
bootstrap, so the next one writes secret:undefined-* — and the plan explicitly
blessed shipping in that state with the removal task outstanding. Task 27 now
applies the default locally, one commented expression with a named owner, and
task 59 deletes it along with the block it lives in. The ARN is correct at every
commit rather than at every release boundary.

That defect was residue from an earlier rewrite of task 23, and the residue had
spread: task 27 pointed at "task 23's rewired ARN" when nothing rewires it any
more, and task 59 removed a blogwright-pds import from nodes.ts that has never
existed. The spec sentence that seeded the phantom step — the CLI's graph
"stops importing" from the plugin — is corrected to say it continues to import
nothing.

The deployment gap is now argued in the terms that matter. Additive-first was
true of commits and false of deployed stacks: the plugin's named policy reaches
a real role only when an operator runs blogwright pds bootstrap, which nothing
else invokes. The pds spec gains an Upgrading a deployed stack section naming
that step as required, and an open question pricing the three self-healing
shapes, including why a config.pds-keyed guard is rejected — it would put back
the topography leak the migration exists to remove.

Also closed: save() was classified as pass-through host surface when it closes
over the site's store, so a plugin graph would have persisted into the site's
state key and never its own — the one context member that typechecks either way.
The pds migration's "nothing changes for users" no longer holds now that pds
contributes a node, and the affected commands are enumerated. Four anchors, a
region-pinning absolute that two global clients legitimately violate, and a
stale claim that four AWS clients "existed in core" when they only ever existed
in a superseded draft.

The remediation also caught what neither review did: task 30 flipped the pds
spec to Merged while one of its Proposed-changes blocks was still outstanding at
task 59. Merge-plan steps 4-5 now execute where the work actually lands.

59 tasks, 107 edges. Graph acyclic with no dangling or forward edges, every task
paired with a certificate, dependencies matching their plan rows, 451 links
resolving, node counts consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A seventh review found one blocking defect and ten others, all of the same
shape: a fix applied in one place while an adjacent file kept asserting the
belief the fix disproved.

The blocking one was the clearest instance. Task 02 declared
ResourceNode<Ctx extends PluginContext> and justified it by parameter
contravariance, but an earlier round had added pluginConfig, siteState and
record() to PluginContext — which OpsContext does not have. So ResourceNode
<OpsContext> violated its own constraint, and the spec rests the analogous
assignability on method bivariance, which task 02 explicitly disclaimed. The
constraint is dropped and the engine is generic over the structural minimum both
contexts satisfy. The genericization had also fallen between tasks 02 and 16
with each deferring to the other; task 02 owns it now.

Also closed: the Firehose stream declared no edge to its own delivery role while
task 54's edge test asserted one, so the two tasks pinned contradictory sets and
the ordering survived only by topoSort's alphabetical accident. A certificate
demanded an init command the spec forbids and discovery would reject. The pds
spec claimed validation outcomes are identical when the settled dispatch-only
decision makes built-in commands stop rejecting a malformed block — a fifth
operator-visible change in a migration that listed four. Plus a task whose
pointer and definition of done contradicted each other inside one item, stale
either/or scaffolding around a settled decision, drifted README anchors, and a
task filename left over from a superseded design.

The rest of this change is a gate, because that failure mode is mechanically
checkable and was being caught by 400k-token reviews two rounds late instead.
.specs/plans/.../type-claims/ carries a transcription of the proposed SPI types,
each citing the spec section it came from, and compiles 29 assertions against
the repo's real OpsContext, OpsConfig, OpsState and PdsContext. Claims the
documents say must fail are pinned with @ts-expect-error, so a claim that
silently stops being true fails the gate too — verified by removing Readonly
from SiteState and watching C13 break on the unused directive. Reintroducing the
constraint this round removed makes it name the broken claim and exit non-zero.

The harness lives outside packages/ and is not wired into CI or the root
scripts; graduating it is proposed, not done. knip, build, test and oxfmt are
all clean with it present.

60 tasks, 108 edges. Graph acyclic with no dangling or forward edges, every task
paired with a certificate, dependencies matching their plan rows, links
resolving, node counts consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bootstrap warning is the one that changes behaviour rather than prose.
Moving the pds grant to the plugin left an upgrade path that only worked if the
operator read the release notes; bootstrap now warns, once per scoped state key
it finds under state/, naming the plugin verb to run. It reads key names only —
not config, not the plugin registry — which is what lets it stay honest about
the two rules it must not break: a config-keyed check would put plugin
topography back in the site graph, and a discovery-based one would cost
bootstrap the discovery-free path the SPI pins with a test. It warns rather than
refuses, and says plainly what it cannot see: a plugin configured but never
bootstrapped leaves no key to find.

The record-expiration question was factually wrong and is now correct. AWS
documents that record expiration is not available for tables you create, and
PutTableMaintenanceConfiguration governs snapshot expiry and compaction — space,
not rows. Aging data out means partition-level deletes the plugin issues itself,
which the append-only day partition makes cheap to shape. Whether to do it at
all stays open; only the false premise is gone.

analytics backfill becomes a declared optional action, reading the CloudWatch
log group the site already writes through core's existing filterEvents. The
second-ingestion-path objection is weaker than it reads: DuckDB is already a
dependency for the dashboard adapter, so this reuses what is there. It is
idempotent by construction rather than by bookkeeping — it only writes whole
days strictly before the day the delivery node recorded at creation, so it can
never race the Firehose path for a row.

Daily salt rotation stands, with the consequence written down rather than
implied: visitor_key never correlates across a day boundary, so a monthly unique
figure is the sum of daily uniques. The dashboard's named queries now say so, so
they cannot imply a distinct count the data does not support.

plugin remove now asks before removing a plugin that owns resources, and refuses
in non-interactive mode rather than defaulting. Removal forecloses the destroy
verb, so answering the default would strand resources with no command left to
reach them.

Two new tasks, 60 and 61, each landing its spec's last outstanding block and so
carrying that spec's status flip. Eleven open questions down to seven, with both
that blocked implementation resolved.

62 tasks, 115 edges. Graph acyclic with no dangling or forward edges, every task
paired with a certificate, dependencies matching their plan rows, links
resolving. Type-claim gate passing at 29 claims; knip, build, test and oxfmt
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antstanley
antstanley merged commit b4ff106 into main Jul 27, 2026
1 check passed
@antstanley
antstanley deleted the spec/plugin-system-and-analytics branch August 2, 2026 08:25
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