# message Guardrails

> Canonical path: `guardrails/message/README.md`

Executable guardrails for the message scope-model cutover.

- [`scope-message-cutover.sh`](./scope-message-cutover.sh) — enforces the scope-message cutover invariants below.
- [`scope-target-access-contract.sh`](./scope-target-access-contract.sh) — enforces the typed scope target/access contract surface and inventories legacy space naming.
- [`task-channel-authority.sh`](./task-channel-authority.sh) — enforces the authoritative Agent Task->board Channel authorizer and forbids direct Task-membership ACL outside state-only allowlists. The wrapper runs the structural production inventory in [`task-channel-authority-inventory.go`](./task-channel-authority-inventory.go): Go AST + constant folding enumerates every non-test SQL expression referencing `scope_memberships` (including concatenated string constants), normalizes identifiers while **preserving security-relevant topology tables/columns** (`scope_id`, `task_scope_id`, `channel_id`, `board_id`, ...) so a Channel query changed to a direct Task membership produces a different digest, and classifies each occurrence against the committed inventory [`task-channel-authority-inventory.gen.go`](./task-channel-authority-inventory.gen.go) — every entry is keyed by the **per-exact-AST-callsite signature** `file::function::line::digest` and carries its classification (`authoritative_path` | `task_state_only` | `reviewed_unrelated`) plus exact file/function/callsite metadata. The checker enforces a **fail-closed three-way bijection** (see `checkWithSets`): every actual direct-membership occurrence must have exactly one committed inventory record AND exactly one independent policy decision (in [`task-channel-authority-policy.gen.go`](./task-channel-authority-policy.gen.go), also keyed per occurrence so differently-classified sibling callsites in one function are independently checked); every inventory and policy record must correspond to an actual occurrence. Missing/stale/mismatched/duplicate/absent-policy cases all fail — an inventory occurrence is never accepted merely because a policy lookup is absent. Mutation-based self-tests in [`task-channel-authority-inventory_test.go`](./task-channel-authority-inventory_test.go) construct/remove/add/misclassify actual, inventory, and policy entries and assert each failure mode plus an exact-closure pass.
- [`scope-target-access-debt-inventory.md`](./scope-target-access-debt-inventory.md) — durable inventory for remaining target/access legacy naming and resolver compatibility recovery debt.
- [`rewind.sh`](./rewind.sh) — enforces the rewind lifecycle-tombstone /
  recovery / projection characterization surface and can print the rewind debt
  inventory with `--inventory`.
- [`rewind-debt-inventory.md`](./rewind-debt-inventory.md) — durable
  non-enforcing inventory of current rewind boundaries and follow-up debt.
- [`direct-no-outbox.sh`](./direct-no-outbox.sh) — enforces the direct
  realtime / no-outbox exception allowlist and fails on new unreviewed bypasses.
- [`direct-no-outbox-baseline.md`](./direct-no-outbox-baseline.md) — durable
  allowlist for current direct realtime / no-outbox special paths.
- [`post-commit-producers.sh`](./post-commit-producers.sh) — enforces the
  autonomous post-commit producer registry: every shared App-service append is
  finalized internally under an explicit producer/owner contract; exported and
  lowercase privateagent write seams, Agent-create handoff seams, and
  MessagePlan applier callers are closed by exact production function/count
  inventories, including the final lowercase `applyMessagePlanTx` physical
  seam; recoverable prepare/emit callsites stay controlled; and the recovery
  flag has one canonical writer. Because the structural checker uses
  `go/parser`, CI runs it from `scripts/ci-backend-required.sh validate`, whose
  image includes the Go toolchain, rather than from the language-agnostic fast
  harness image.
- [`post-commit-producer-inventory.md`](./post-commit-producer-inventory.md) —
  durable autonomous producer/owner inventory, including intentional no-notify
  exceptions and the explicitly deferred Approval feedback path.
- [`message-semantics.sh`](./message-semantics.sh) — enforces message semantics
  registry coverage, public-create policy wiring, visibility parity coverage,
  hidden/envelope allowlist coverage, and the direct `scope_messages` insert
  boundary.
- [`delivery-intent.sh`](./delivery-intent.sh) — enforces the typed
  `DeliveryIntent` outbox builder boundary so message fanout uses the canonical
  wrapper and non-message source refs are not hand-built in production code.
- [`author-source-substrate.sh`](./author-source-substrate.sh) — enforces the
  author/source substrate contract: core author families stay `human | agent |
  service`, future service kinds are not pre-reserved, trigger/provider/app
  causality is treated as source/run rather than author, and old author/source
  surfaces must be disposed through the inventory.
- [`author-source-substrate-inventory.md`](./author-source-substrate-inventory.md)
  — durable old-surface disposal inventory for author/profile/source substrate
  cleanup.
- [`author-service-kind-contract.sh`](./author-service-kind-contract.sh) —
  keeps the backend `supportedAuthorServiceKind()` wire allow-list and every
  frontend re-declaration of it in lockstep (4 zod validators, the realtime
  socket guard, the display normalizer, and both TS type unions), so a backend
  kind added without the matching frontend acceptance cannot reach CI green (a
  drifted page fails to parse as `Invalid messages response` and realtime
  payloads get dropped). Behavior is locked by
  [`author-service-kind-contract-test.sh`](./author-service-kind-contract-test.sh)
  through per-surface mutations and equivalent-formatting rewrites.
- [`contribution-source.sh`](./contribution-source.sh) — enforces the typed
  message contribution source/read-model boundary so visible messages expose
  canonical source/run fields instead of treating trigger metadata or author
  fields as source truth.
- [`unread-compat-builders.sh`](./unread-compat-builders.sh) — enforces the
  exact-unread compatibility boundary: legacy database aggregation helpers stay
  store-local, explicit sidebar unread compatibility reads are runtime-backed,
  and first-paint, workspace switcher, heartbeat, route-independent bootstrap,
  and warm-refresh frontend paths cannot call exact unread endpoints or query
  options.

## Live-cut table target

The final message read/delivery/fanout runtime must converge to scope-owned facts. Old
channel/space/thread tables are not long-term runtime dependencies: when a legacy
row still carries current behavior, add the missing field or constraint to the
scope model, migrate the data, and delete the legacy table. Do not retain a
legacy table as compatibility glue.

### Target scope tables

Core message-domain runtime targets:

- `scopes` — channel, DM, and reply-thread container identity/topology.
- `scope_channel_profiles` — channel-only profile fields such as slug, display
  name, description, visibility, owner, and all-channel flag.
- `scope_memberships` — human/public-agent/private-agent membership, visibility,
  owner marker, membership interval, and human read cursors.
- `scope_notification_settings` — active/quiet/mute/auto-wake/no-wake state for
  every scope member.
- `scope_private_grants` — owner-scoped private-agent authority grants.
- `scope_messages` — the only message transcript fact table.
- `scope_message_sources` — source/root/forward/context provenance.
- `scope_activity_events` — non-message activity only; never message-created,
  mention, explicit recipient, or controlled broadcast rows.
- `scope_fanout_outbox` — transactional outbox jobs. A normal message writes one
  message-level fanout job, not a recipient ledger.
- `scope_message_reactions` — current reaction state; reaction delivery/diagnostics uses
  `scope_activity_events` when needed.
- `message_recovery_points` — rewind recovery facts keyed by scope/message.
- `scope_message_search_docs` and `scope_message_search_state` — search
  projection and readiness.
- `attachments` and `scope_share_cards` only where current attachment/share
  features require them, with scope/message keys.

Task ingress remains in task-native context, discussion, and audit tables. It is
not represented as message delivery, fanout, or activity rows.

### Legacy tables that must disappear from final runtime

These tables and equivalent replacements are deletion targets, not optional
compatibility surfaces:

- Message/delivery/search/runtime ledgers: `space_messages`,
  `space_message_sources`, `targeted_delivery_events`,
  `space_message_deliveries`, `message_search_docs_tidb`,
  `message_search_tidb_state`, `scope_delivery_events`,
  `scope_message_routing_annotations`, `scope_runtime_events`, and any
  equivalent scope runtime/delivery ledger.
- Conversation/profile/topology/read/subscription tables that must fold into the
  scope model: `channels`, `channel_profiles`, `threads`, `reply_threads`,
  `reply_thread_stats`, `dm_threads`, `spaces`, `space_participants`,
  `space_subscriptions`, `thread_subscriptions`, `thread_read_states`,
  `channel_agent_entries`, and `channel_share_cards`.
- Share/timeline old naming: `channel_share_cards` must fold into
  `scope_share_cards` when the share feature is kept.

Upgrade migrations and legacy fixtures may read old tables as input. Fresh
schema, shipped runtime code, final-state docs, and live-cut tests must not treat
those tables as runtime truth. If an old table cannot be removed because it
contains a current product behavior, stop and propose the required scope-model
addition in the channel before implementing the deletion. The review output must
list the old table, the behavior it still carries, the target scope table/field
that should absorb that behavior, and the deletion test that proves the old table
is gone.

### Inventory helper

Run the inventory mode while the cutover is in progress:

```sh
guardrails/message/scope-message-cutover.sh --inventory
```

The report separates fresh bootstrap schema, upgrade migrations, TiDB migration/admin
tests, shipped runtime code, runtime tests/fixtures, non-table protocol identifiers,
and final docs. It includes message/delivery/search ledgers plus SQL table-reference
matches for conversation topology/read/subscription deletion candidates. Runtime code
and schema sections match SQL verbs such as `FROM`, `JOIN`, `CREATE TABLE`, and
`REFERENCES`; final docs only match code-style table names when the same line has
table/schema/migration/FK/index/field or SQL context. Product words like `join
channels`, API JSON fields such as `"channels"`, local variables named `threads`, and
CLI flags such as `--threads` are not treated as old table dependencies. Source-kind
literals such as `visible_reply_threads` are listed separately as protocol identifiers,
not SQL table dependencies. Deleted runtime table matching is token-aware: descriptor
names such as `scope_search_workspace_messages_for_backfill` are not old-table
references just because they contain `space_messages` as a substring. Only
upgrade-input, TiDB old-database migration fixtures, and reviewed legacy fixture
sections may contain old table references during the migration window; all other
sections must be empty before final live cutover.

### Upstream TDE optimization mapping

Upstream optimizations added to the old `targeted_delivery_events` ledger must
not be carried forward on that table, but their query intent must be checked
against the scope model before deletion:

- `consumed_at` / `suppressed_at` GC indexes on TDE map to scope-owned cleanup
  paths. Fanout reliability and retry cleanup use
  `scope_fanout_outbox_pending_idx`, `scope_fanout_outbox_lease_idx`, and
  `scope_fanout_outbox_event_ref_idx` over status, next attempt, lease, and
  event reference. Non-message activity suppression/dedupe uses
  `scope_activity_events_ref_key` plus scope/global and source-message indexes.
  If a new cleanup worker needs a different access pattern, add the index to the
  relevant `scope_*` table with a TiDB regression instead of restoring TDE.
- Human mention/read-state acceleration from TDE maps to `scope_memberships`
  cursors plus `scope_messages_scope_global_idx` /
  `scope_messages_scope_author_global_idx` for message receive and unread scans.
  Reaction/activity receive uses `scope_activity_events_scope_global_idx`; the
  reaction state table is indexed by message/emoji/member and scope/message.
- The old TDE GC/read-state migration and bootstrap indexes are intentionally
  not part of the scope live-cut runtime. Reviewers should require an explicit
  scope-table index and test for any missing performance coverage, not a legacy
  ledger index.

## Invariants

- Removed planning/fact tables and contracts stay removed: no `PrivateVisibilityMutations`, private context event table, routing epoch table, or removed delivery ledger can re-enter active scope runtime/docs.
- Active docs use `scope-message-search-*` admin commands; old message-search command names are not valid current guidance.
- Scope history/search docs use `scope_id` and scope indexes, not the old `(space_id, seq)` scan contract.
- Scope search must not fall back to `LOWER`/`LIKE`; detailed TiDB FULLTEXT
  readiness/finalize behavior is covered by store and migration tests instead
  of path-specific guardrail string checks.
- Scope search migration must not inherit pre-scope `read_ready` or
  `finalize_status`; exact reset/projection shape is covered by TiDB migration
  tests instead of brittle guardrail file-content checks.
- Message-backed delivery events are not part of the scope component model: message rows are the message source, while `scope_activity_events` is reserved for non-message activity.
- Message-backed routing annotation tables are not a replacement for delivery events. Mention / explicit / controlled-broadcast reasons may live in the current message-level fanout/outbox payload or be derived from the message plus active membership/settings; they must not use a separate message-backed routing table or a scope runtime ledger.
- Message diagnostics must not use message-delivery runtime evidence, durable delivery facts, or agent ledger wording. Diagnostics explain scope membership, notification settings, outbox status/error, and current runtime lifecycle state only.
- `scope_activity_events` must preserve non-message-only rows;
  message-created rows and `activity_ref_type='message'` are invalid. The schema
  constraint name/shape is covered by migration tests.
- `specs/storage/storage.md` shipped/final-state text must use final scope facts (`scope_messages`, `scope_activity_events`, `scope_memberships`, task-native ingress) instead of legacy or intermediate delivery/storage names.

If this guardrail fails, fix the underlying code or canonical docs. The script is
intentionally limited to broad cutover regressions and deleted-table inventory:
path-specific search/schema implementation shape belongs in focused Go/TiDB
tests, not in missing-file guardrail checks that can fail on stale or partial
worktrees.

- `timeline-card-retirement.sh` keeps the retired generic timeline-card / system-card foundation out of current runtime, web, and current specs. Historical migrations, fixtures, and the retired timeline-card spec are intentionally excluded.
- `message-semantics.sh` keeps message kind policy centralized. Message types
  must be registered in the semantics registry; `system.card.kind` is retired as
  an active extension point, so production writers must reject it and migrations
  must migrate or delete old rows instead of adding new card kinds or read
  fallbacks. Go and SQL human-visible predicates must stay under parity
  coverage; hidden or envelope message rows must remain explicitly allowlisted
  with migration and removal metadata; public human-create routes must ask the
  registry before accepting a message type; and production `scope_messages`
  inserts must stay behind the canonical message store boundary instead of
  scattering raw writes.
- `delivery-intent.sh` keeps scope fanout outbox construction centralized.
  Production code must build message and non-message fanout rows through the
  canonical `DeliveryIntent` builder boundary; tests may use fixtures, but
  production code must not hand-build non-message source-reference fields.
- `author-source-substrate.sh` keeps the author/profile/source substrate
  contract honest. It forbids product-specific core sender families, prevents
  pre-reserved workflow or bot service-kind enum slots, and requires old
  author/source surfaces to be deleted, migrated, or explicitly documented as
  compatibility-only in the disposal inventory.
- `non-task-legacy.sh` keeps pre-task non-task legacy message surfaces closed.
  `internal_automation`, the retired `internal_workflow` / `workflow_command`
  handoff, and `system.card.kind` remain documented in the non-task legacy
  inventory only as migrated or deleted shapes. Retained hidden
  `internal_automation` transcript rows and their old message-source outbox rows
  are deleted by migration so old envelopes cannot replay after the message row
  is removed. Active Workflow Run effects create ordinary visible messages with
  native `workflow_run` contribution sources and typed `WorkflowContext`
  callback metadata; active product writers must not recreate the retired
  runtime-only workflow handoff, hidden transcript envelopes, or system-card
  metadata.
- `contribution-source.sh` keeps source/run explanation separate from message
  authorship. Visible message read models must expose a typed contribution
  source, trigger delivery must pass source facts through the message write
  boundary, and trigger metadata remains a narrow compatibility adapter rather
  than the canonical source model.
- `unread-compat-builders.sh` keeps exact unread count compatibility lazy and
  intent-scoped. Warm shell, workspace polling, socket heartbeat/reconnect, and
  route-independent bootstrap paths may use owner-local runtime or cached hints,
  but they must not call legacy exact unread database aggregation, removed
  legacy conversation unread-summary helpers, or exact unread frontend query
  options.
