design: generic feature packs, neutral event vocabulary, declarative custom features - #8
Merged
Merged
Conversation
…ative custom features Splits the built-in features into namespaced core/email/brand packs enabled per tenant, adds a neutral delivery.sent event with a read-side view over content.sent, product-declared resource kinds, channels and custom types with kind-driven redaction, and a closed declarative feature DSL (count, distinct, share, peak, time_between, history-relative modifier) with mandatory caps and a static cost model. A frozen canonical-key alias table keeps input hashes, local-scorer summation order and version hashes identical, proven by a golden replay of every fixture. Adds a pointer in the main design and a plan row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
…rev 2) Drops the canonical-key bridge for a one-time rename with a per-consumer test list and a semantic-identity golden (derived ulp bound). Replaces the event-count bound and wall-clock deadline with time-bounded loading, full onboarding loads, byte caps, a deterministic step budget and truncation as a positive signal. Closes redaction channels: re-HMAC of every hash (length-prefixed, join domains), undeclared values dropped, PSL-checked domains, card/IP/phone scanning, skeleton-only custom text. Adds group_by, sequence, ratio, neighbours with declared link kinds, before_first, and subject kinds, and walks five fictional scenarios. Drops delivery.sent for declared types with field roles. Re-slices into P0-P7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Onboarding from ingest-maintained subject_facts and lifetime totals from subject_counters; evaluation classes F/N/A/R/G/D with a fixed pass order and per-feature/per-pack budgets; exact per-feature aggregates; truncation becomes a one-sided `partial` flag, not a weighted feature; flood property restated against an unbounded reference with a specified generator. Rename is bit-exact via registry-order summation; fake scorer and corpus loader covered. Redaction: author-trusted bounded numbers, domain eTLD+1 with allowlist-or-HMAC, name grammar for undeclared fields, pseudonymised non-account ids, HKDF per-tenant keys, egress scan. DSL: absence indicators, pre-transform ratio, exact group_by, hash_quantum, as-of neighbours. Slices re-split (P1s, P3a-d, P4a-d, P5b). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Peak saturation sized in raw units per transform (and after the baseline), with partial+degraded when the row budget binds first; neighbours exact by saturation with where-before-limit; partial/degraded direction table (fixes the backwards fan-in claim); start defined by precedence (account_created_at, first accepted subject.created, server first_received_at) with anchored-fact invalidation; lock-first fact updates and bounded decline recount on inf->t and earlier moves; webmail counters subtract (now, +inf); facts/counters subject assignment for also/via_parent and type/at on event_subjects; ratio partial propagation. Plus the listed text fixes, slice fixes and a section 13 revision-4 addendum. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Recount-backing counters (decline, before_first) are primary-subject-only, with a parent/child recount test; "first accepted" is the smallest (received_at, producer, id) and determinism holds with received_at fixed; peak worked examples corrected (streams complete; binding is decided at run time; x_sat fixed); READ COMMITTED ingest with bounded retry and hourly decline counters plus a boundary-hour recount; class N features rejected as ratio den and negative-sign ratios over partial-capable nums flagged degraded; start clamped to first_received_at. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Owner decision: the compiled binary carries no domain knowledge. Email becomes a YAML reference pack (packs/email) loaded like custom features; content.sent, email_hash, email_domain_class and address_domain move into its declared vocabulary with byte-compatible wire handling (pack extensions of built-in types, flat declared links map). New generic DSL primitives (baseline override, distinct.on_missing, versioned compat options, lifetime share, brand_match, cross-field constraints) give a bit-for-bit parity table for every PR #7 email feature; P-E1 loads the pack in shadow, P-E2 proves parity and deletes the Go email code. Core audit, brand pack lists as data, neutrality CI, non-email reference packs, new decisions on pack location, visibility and pinning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
brand_match gains declarable exemptions computed at score time from a display-name fact table, per-role matcher variants, match after masking, and standalone age_decay; self-send uses eq:false with before_first include_future; one explicit monotone baseline shared by the four history-relative email features; embedded SHA-addressed reference packs and fail-closed 503 ingest on config_error; hash_quantum 0 and a rescore legacy_v0 mode for bit-exact NextRescoreAt; canonical links serialisation with a byte-identity test; derived column; completed neutrality audit with a P-N0 cleanup slice; stricter neutrality tests; closed compat enum; shadow-mismatch gate before P-E2; emailshadow namespace bound at load; decisions updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
The owner decided to replace pkg/abusekit Links with a map in place. It is pre-GA with no external consumers, so this ships as a breaking change noted in the release notes, with no v2 module path and no retirement window. Updates the audit row, P-N0 done-when, decision list and a revision-7a note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
jiashuoz
added a commit
that referenced
this pull request
Sep 29, 2026
Merges origin/main (PR #5's S4 eval harness + PR #8's design doc) into this branch. The merge itself was clean except for a doc-only conflict in eval/fixtures/README.md (both sides appended a new section); resolved by keeping both sides' content, generic wording only. The merge combined cleanly at the text level but not at the type level: eval/replay.go's feature.Extract call predates S2b's WebmailSet parameter, so the merged tree didn't build. Fixed by threading a feature.WebmailSet parameter through LoadReplayDataset (added right after brands, mirroring internal/feature.Extract's own parameter order) and every call site, test helper, and CLI flag that constructs one: - eval.LoadReplayDataset(in, brands, webmail, benignLabel) — webmail flows straight into every feature.Extract call, no package-level global, no panic on a zero value (feature.WebmailSet{} just matches no domain, same as passing no webmail config at all). - `abusekit eval` gains --brands-extra and --webmail, both named, env-var'd, and defaulted exactly like `serve`'s own flags (main.go's parseServeFlags) — the harness now loads brands/brands-extra/webmail the identical way a real deployment does, not a silently different subset. - New test: TestLoadReplayDataset_WebmailRecipientShareIsNonZero proves a webmail-heavy replay subject scores a non-zero webmail_recipient_share through the harness, and that the SAME subject scored against an empty WebmailSet reads back to 0 — proving the parameter is load-bearing, not merely accepted (verified by temporarily reverting the wiring and confirming the test catches it). F9 TODO: added two new eval/gen families (abusive_webmail_blast, abusive_subject_lure) — every other family's recipient_domain is a synthetic .example.test name and no family ever sets subject_line at all, so webmail_recipient_share/webmail_sends_1h/subject_brand_match otherwise read 0 across the ENTIRE synthetic corpus regardless of the harness wiring above. webmail_blast sends to real consumer webmail domains (config/webmail.yaml's own list, a public fact); subject_lure uses a fictional brand in its subject line (eval/fixtures/ test_brands.yaml, never a real one — this repo's hygiene rule for fabricated lure prose, a stricter bar than a bare resource name). Regenerated eval/fixtures/synthetic/{events,labels}.jsonl deterministically from the documented seed (20260927) — 20 families, 297 subjects (was 18 families, 286). Makefile's gate target now passes --brands-extra eval/fixtures/test_brands.yaml so the fictional lure brand is recognized when scoring this corpus. The new features and families change scores, so eval/floors.yaml was re-derived against a fresh run, same margin policy the file documents, weights untouched: precision 0.8036->0.8169, recall 0.8333->0.8788, AUROC 0.9735->0.9819, high-tier recall 0.7222->0.7879 (all IMPROVED or held family-steady: burst 0.8333, churn_incarnation_ge3 1.0, dormant_then_blast 1.0 unchanged) -- min_precision/min_recall/min_auroc/min_high_tier_recall/ family_min_high_tier_recall floors are UNCHANGED, now with MORE margin, not less. ONLY max_ece broke: baseline ECE moved 0.1108->0.1265 (an expected calibration cost of seven new hand-set, unfitted weight dimensions, not a regression), already past the old 0.115 floor before any weight was touched. Re-derived 0.115->0.132 (baseline+0.0055, same tight-margin policy T5 documented), confirmed to still catch subject_age_h (zeroed ECE 0.1509) and upgrade_delay_min (zeroed ECE 0.1378) -- TestGate_NegativeWeightRegressionCaughtByTightECEFloor passes unmodified. Hygiene check clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
abusekit's current feature set assumes an email platform. This design,
docs/design/2026-09-29-generic-feature-packs.md(now at revision 7), makes abusekit a generic abuse-detection system for any SaaS product. It assumes #5 (S4 eval harness) and #7 (S2b features) have merged.One-time rename. Built-in features are renamed once into namespaced
core,emailandbrandpacks, and each tenant enables the packs it needs. The local scorer now sums features in a fixed registry order, so every score, tier and feature value stays bit-for-bit identical after the rename. Every consumer of feature names has a test.Per-tenant profiles live in a private config mount. A product declares its own event types, field kinds and roles, extension fields on built-in types, link kinds, and subject kinds beyond accounts (cards, API keys, customers).
Pseudonymising redaction:
max;Evaluation combines:
Hitting a bound sets a one-sided
partialflag on the verdict and is never used as a weight. Floods cannot lower risk: this holds against an unbounded reference and is tested with a specified flood generator.A closed DSL: count, distinct, share, peak, time_between (with absence indicators), sequence, exact group_by, ratio, neighbours, and relative_to_history (with exact equations).
Walkthroughs of five fictional scenarios, each stating plainly what remains Go-only.
Slices P0–P7. P1 (the rename) must land before any S5 or S8 PR. Bounded evaluation does not reach e2a until P4a–P4c have landed.
This PR is docs only: the design doc, a pointer line in the main design, and a row in the plan.
Decisions needed from the owner
Where the re-review's (R3) answer differs from the earlier recommendation, both are shown.
Rename vs bridge. Rename once in P1. R3 agrees and asks for a bit-exact golden replay. Now: summing in registry order makes it bit-exact, and the 1e-12 tolerance is deleted. Approve?
Ordering. P0 and P1 land before any S5 or S8 PR, enforced by the plan and a test. Approve?
Undeclared data. Drop the values and keep only the field names. R3: also constrain the names. Now: names must match
^[a-z0-9_]{1,64}$, at most 32 per event. Approve?Re-HMAC of built-in fields. Re-HMAC
recipient_hashand every link, with a dev/staging migration job. Approve?Legacy window quirks. Freeze them in
@1, harmonise them in@2. Unchanged. Confirm?Roles. Revision 6 adds the field roles
display_name,destinationandrecipienttocredential,other,activity,titleandself. Confirm?Bounding.
Revision 4 adds raw-unit
peaksizing and saturation-exactneighbours. Anything that can undercount isdegraded. Confirm the 50,000-row budgets (baseline,peak, anchored, neighbour) andmax_groupsof 1,000?Bootstrap. Uniform priors, shadow-only, no fitting, held-out fixtures, and
__absentindicators. Confirm?CEL. Allow it later as a predicate leaf only, or require a new design pass? Unchanged.
Brand list. Can a tenant narrow the list as well as extend it? Unchanged.
Custom namespace.
custom.*per tenant, or<tenant>.*? Unchanged.Config history. Keep it in the config tree and check it in CI; the DB is only a guard. Approve?
S6. Emits
content.sent, unchanged on the wire. From P-E1, the email reference pack declares it, not the binary. Settled.Domains.
recipient_domainkeeps the full domain (reduce: none) and is HMACed. Reducing to eTLD+1 would merge distinct fixture domains and changeemail.first_day_distinct_domains, whereas HMAC is injective.Also: accept that text scorers see a token for unknown link hosts?
ratioproduct form. Add a capped product form, or wait for fitted weights?Subject kinds.
subject_kindandalso(at most 3),via_parentindex rows (at most 8 per event), and pseudonymised non-account ids. Approve?Stage gate. Advise-mode local rules only, as its own slice (P1s). Approve?
Profiles and keys. Profiles in a private mount; a provider-agnostic
Keysinterface with HKDF per tenant and purpose. Approve?Rescore control. Proportional coalescing for DSL features only, and timers only from non-shadow rules. Confirm a budget of 20 × active subjects per hour?
group_byadmission.GROUP BYin the aggregate engine, with space-saving only as the in-memory adapter's memory bound, flaggedpartial.Approve?
Declared numbers.
maxrequired, no Luhn.Approve?
partialflag. Revised in revision 4: a barepartialmeans the value can only have risen, and any source that may undercount also setsdegraded. Should callers still treat a barepartiallikedegraded?startprecedence (new).account_created_at, then the first acceptedsubject.created, then serverfirst_received_at, replacingLEAST(at). Approve?Key-independent
body_hash(new). Computed over the redacted body before pseudonymisation, so rotation and migration never break dedupe. Approve?Dirty marks beyond the fan-in cap (new). The first 50 are marked in the ingest transaction; the rest by a rate-budgeted background job. Confirm?
Reference pack location. Adopting the review's answer: packs are embedded in the binary and addressed by content SHA through a release manifest. Tenants may add packs only in non-reserved namespaces and can never shadow a reference pack.
Public reference packs. Yes. Starter weights and floors come from synthetic fixtures only.
Pinning. Revision 6 allowed floating pins everywhere. Now advise-mode profiles must pin
name@x.y.z#sha256:…; floating pins are allowed only in shadow-only profiles and in dev.Tenant packs. No
compat, no shadowing, must follow the naming grammar, and may extend built-in types only withx_-prefixed names.compat. Revision 6 had free-form options. Now it's a closed enum in the engine, with a conformance test per option:window_end_closed,include_future,before_first_include_future(new) andrescore_legacy_v0.P-E2 clean-days gate (new). How many days of zero unlabelled shadow mismatches are required before the Go email code is deleted? Proposed: 7.
Go SDK links change. DECIDED (owner). Replace
pkg/abusekitin place:Linksbecomes a map, as a breaking change on main noted in the release notes and changelog. No v2 module path and no parallel versions.recipient_countbounds (new).min 1, integer, max 1000000. Please confirm this is at least e2a's largest per-message recipient count.Rework (revision 2, after adversarial review)
Blockers
core.stageSkip(and howRule.Stagekeys are handled), the quantization switch,inputHash, the eval cassette key and header marker, therun.jsonSHAs and key-space marker,renderReasonwithreason_version,corpus_examples.featureswith afeature_key_spacecolumn and migration,local.Version(), andscore --jsonl, which now returnsfeature_renamed.subject.*,payment.*,subscription.*) always load in full, and byte caps apply.core.history_truncated, which carries positive weight. Rules are never marked unscored for this.packtestenforces the truncation invariant.group_by(max or count_gte reduce, with capped groups);sequence(with anonjoin);ratio(a depth-1 DAG); declared link kinds plus aneighboursop;before_first;anchor: last; and subject kinds withalsoandparent.Should-fix
delivery.sentdropped. Replaced by declared types withtitleandselffield roles, anactivitytype role that core velocity and burst features include, andx_extension fields on built-in types.vocab_version.(at, producer, id).PriorSignonFeatureDef.eval. The dependency errors in revision 1's G4/G5/G6 are fixed. S3b erasure is now required to be vocabulary-aware.credentialandotherroles ship. The key interface is provider-agnostic. Scorer versions are reported per tenant.peakclips sub-windows to its outer window. The uniform-prior normalisation is written out.Revision 3 (after re-review of 3c1c32c)
B2 (open in revision 2):
subject_facts, maintained at ingest with monotone updates and an indexed recount when the first success moves. No onboarding scan is byte-capped. A flood of 10,000 tiny blocked payments provably moves no onboarding feature.subject_counters, so no feature can fall when history is truncated.TruncationDiris deleted.partialflag on the signal and the subject. It is not a weighted feature and does not setdegraded. The flag only ever errs toward higher risk; uniform rules just record it. The truncation invariant and its packtest are deleted.TestFeatureIndependencechecks that changing one feature never moves another.risk(flooded, bounded) ≥ risk(flooded, unbounded reference). The generator covers read and unread types, onboarding types, placements (before, interleaved, after, edges, future-dated), minimum-size events, and 1×/10×/100× saturation. Each evaluation class carries an exactness argument, including whypeakis exact under its saturation limit.B1:
feature_renamedinLoadSnapshotCorpus, plus thecorpus-v2schema.deterministicProbsis re-baselined in P1.FeatureDef.Order(the pre-rename flat order), so the golden replay is bit-exact and the 1e-12 machinery is deleted.Boundis defined for the velocity features as a normalisation ceiling, never a cap.B3:
maxis required and there is no Luhn check.first_link_hostandrecipient_domain. Therecipient_domainexception to eTLD+1 is explained in decision 14.^[a-z0-9_]{1,64}$, at most 32 per event.alsoids are pseudonymised, and account ids are leak-scanned.Keysderives a distinct key per tenant and purpose with HKDF; a cross-tenant inequality test covers it.subject_linemasking is clarified.body_hash.B4:
time_betweenandsequencehorizons feed each feature's store range.if_absentplus a derived__absentindicator with a mandatoryabsent_sign, so abandonment is never the benign extreme.log1p.ratiouses pre-transform values.group_byis exact (space-saving is only a fallback).hash_quantum, defaulting to 60 minutes foruntil_now.peakacceptssum, andsharesums both numerator and denominator.neighboursis evaluated as ofnow, matchingeval/neighbors.go.core.linked_*.event_subjectsis reconciled: at most 8 rows per event, withvia_parent.Slices:
titlerole) and P4c; P6a needs P3b.TestNoVendorAdapterBeforeRenamechecks it.Revision 4 (after verification of 427d462)
Blocking fixes (P4a, P4c):
peaksaturation is now sized in raw units per transform:C,⌈e^C − 1⌉or⌈e^(C/s) − 1⌉. For history-relative features it becomes⌈max(B,1)·min(rc, T⁻¹(C)/d)⌉, computed after the baseline. The row limit isx_sat·⌈W/S⌉, and if the budget binds first the feature is flaggedpartial+degraded. Both counter-examples are worked through.neighboursapplieswherebefore any limit and counts up to⌈T⁻¹(cap)⌉ + 1subjects, so the value is exact by saturation. A budget hit setsdegraded. Legacycore.linked_*caps now also setdegraded. The backwards direction claim in §5.7c is corrected with a per-source direction table.startis defined by precedence:account_created_at, then the first acceptedsubject.created, then serverfirst_received_at. Criterion 6b is restated. Frozen class-N facts carryanchored_startand are recomputed whenstartchanges or a backfill event lands in the anchor.∞ → tand on earlier moves.(now, +∞), which covers backfill keys that are exempt from the skew check.also/via_parent. Onboarding facts are kept for the primary subject only. Counters are kept for every index row of a matching kind.event_subjectsgainstypeandat, with a matching index.ratio. Partial status propagates. A partial-capabledenis rejected at load (ratio_den_partial_capable).Text fixes:
maxwith a positive sign stays upward. Everything else isdegraded.+or grouping, so 10-digit ASNs pass.in_seton domain fields is evaluated at ingest.body_hashis key-independent.distinct_recipients_1his consistently A+R.before_first,lifetimeandneighbours, plus a genericbefore_firstfact spec.age_decaygets ahash_quantumdefault.address_domainexception is removed.absent_signis-.Slices:
TestNoProductionBeforeRenameguards S8 as well as S5.Decisions: Q7 and Q22 are revised. Q23–Q25 are new.
Revision 5 (after the final check of a234623)
before_first) now count only the primary subject, soalsoandvia_parentrows never feed a recount.TestRecountParentChildcovers this.(received_at, producer, id). Determinism now holds with each event'sreceived_atfixed, and the a2 tests takereceived_atfrom the fixtures.peakworked examples are exact: their streams complete at about 2,200 and 48,186 rows. Whether the limit binds is decided at run time, andx_satis corrected to50·min(300, T⁻¹(C)/d).ratiodenominator. A partial-capable numerator combined with a negative sign is flaggedpartial+degraded.startis clamped so it is never later thanfirst_received_at.startclamp.Revision 6 (owner decision: domain-neutral binary)
coreandbrand. §5.0 defines what "built-in" now means.packs/email/(pack.yaml+webmail.txt) is loaded like custom features.content.sent,email_hash,email_domain_classandaddress_domainmove into its declared vocabulary. The wire stays byte-compatible, via pack extensions of built-in types and a flat declaredlinksmap.distinct.on_missing, versionedcompatoptions, lifetimeshare, abrand_matchop, and cross-field constraints. A parity table covers all nine S2b: send-volume, webmail, recipient and subject-brand features #7 email features.burst_ratiois generalised toactivitytypes.email_domain_class_disposablemoves to the email pack. The evidence link set is declared.titleanddisplay_name. The brand list, integration tokens and community phrases are now data.TestCoreIsDomainNeutral(an AST denylist),TestNonEmailProfileEndToEnd(card testing with no email pack loaded), andTestGoRegistryLint.card-testingandapi-credential-abuse. e2a stays bit-identical throughout.compatoptions.Revision 7 (after focused review of a80527d)
H1:
brand_matchgets declarable exemptions (exempt: {type, where, require_token, match_variant, live_unless}), computed at score time from a display-name fact table. The engine pins the matcher variant per role: for titles the community gate is on and the integration gate off; for display names it's the reverse. Matching runs after masking. Standaloneage_decayis defined asT(min(raw, T⁻¹(cap))·d). The "Stripe API Key" counter-example is a committed parity fixture and gives1·d.H2: "external" in self-send now means
eq: false, so a missing field doesn't count as external.before_first_include_futureis added tocompat.H3: the four history-relative email features share one explicit baseline,
B10. The baseline override gainssum/cap_eachand accepts only monotone ops.H4: reference packs are embedded in the binary and addressed by SHA. A tenant in
config_errorgets a retryable whole-request 503; nothing is dropped. There's a new test for this.H5: all nine features set
hash_quantum: 0, and arescore: legacy_v0mode reproduces Go's rescore candidate set. Together these makeNextRescoreAtand the input hashes bit-exact.M1: the
linksmap has a canonical serialisation.TestWireByteIdentitychecks stored bytes,body_hashand error codes. An unknown link key still returns a whole-request 400.M2: derived fields move to a separate column, outside
body_hashand the 8 KiB limit. Addsstore: raw+skeleton, andrecipient_countgets bounds.M3: the audit now covers everything outside the features themselves:
reason.go;Linksreplaced in place, per the Q32 decision);eval/neighbors.goandeval/gen;WebmailSet, thecontent.sentmatches,allLinkKindsand"agent";A new cleanup slice, P-N0, handles it.
M4: the neutrality test now:
packs/and_test.go;(file, identifier), with an explicit rule for "user agent";Readsat runtime;M5: only packs listed in the manifest count as reference packs. Reserved names can't be shadowed, and
compatis a closed enum.M6: a production shadow-mismatch metric, a clean-days gate before P-E2, and a list of production-only divergences.
M7: the P-E2 parity oracle is the Go code after P4a.
M8: the shadow namespace is
emailshadow, bound at load time, so the pack SHA stays byte-identical all the way to ship.L1–L4: the exact
age_decayfloat expression; an unreachable cap;if_empty: 0; an IDNA note;display_nameis opt-in per resource kind.Slices: P-N0 comes first. P-E1's done-when adds byte identity. P-E2 requires embedded packs, fail-closed ingest, the ops pin and compose change in the same release, and the clean-days gate.
Decisions: Q26–Q30 now use the review's answers. Q31–Q33 are new. Revision 7a: Q32 is decided, so the SDK is replaced in place.
🤖 Generated with Claude Code
https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8