SSOT for bridging factory partner onboard to Soft-desk package Telegram groups without crossing plane ownership.
| Role | Doc / code |
|---|---|
| This spec | partner-package-group-handshake.md |
| Partner identity spine | partner-onboarding-package.md |
| Factory Telegram | telegram-factory.md |
| Package title grammar (factory) | lib/telegram/surfaces.ts formatPackageGroupTitle |
| Package title grammar (ct) | toc-ops-repo/src/central-tool/telegram-surfaces.ts packageGroupTitle |
| Registry | lib/telegram/package-group-registry.ts |
| Onboard emit | lib/operations/partner-onboard-package.ts |
| Link CLI | tools/telegram-ops.ts link-package-group |
| Machine reference | bun run telegram:handshake:catalog — constants, lanes, verify checks, CLI flags (JSON: --json) |
Doc rule: this file is the operator runbook only. Do not add new constant tables elsewhere — extend handshake-catalog.ts and regenerate the catalog.
| Plane | Owns | Must not |
|---|---|---|
| Factory (this repo) | DM link, welcome templates, package_group_registry, pending JSONL artifact |
Create Soft groups via Bot API; mutate Soft ledger |
Soft desk (ct) |
Package forum creation, telegram_ref on partner package, MessageLog |
Factory ops_chat_channel_meta for package groups |
tree_nodes.telegram_id remains the DM link only.
Two different topic grammars. Do not apply house-surface topics to partner package forums (or vice versa).
| Kind | One chat per | Topic SSOT (code) | Thread ids |
|---|---|---|---|
| Partner package forum | Partner CODE (SPEN, ASH, BIL, …) |
package-group-forum.ts PARTNER_PACKAGE_FORUM_TOPIC_PLAN |
Per chat: reports/telegram/forums/{CODE}.json |
| House surface | Concern (hq, sandbox, all-accounting, …) |
surfaces.ts TOC_OPS_SURFACES |
Per surface via TELEGRAM_SURFACES env |
Machine dump: bun run telegram:handshake:catalog --json → packageForumTopics · houseForumTopics.
Title grammar: TOC Ops · {CODE} · {DisplayName}. Identical topic titles on every linked partner forum. Only numeric thread ids differ per chat.
| Topic title | topicsThreadMap key |
Role |
|---|---|---|
| General | general |
Implicit thread 1 |
| Ops | ops |
House ops |
| Alerts | alerts |
Outbox alerts |
| Liquidity/Outs | liquidity/outs |
Pinned seat desk + rails pipe |
| Accounting | accounting |
Deposit/withdraw proof |
Map key rule: title.toLowerCase() — titles are case-sensitive Bot API names (Liquidity/Outs, not Liquidity-Outs).
Ensure full plan on all linked partners:
bun run telegram:package-group:enhance CODE --ensure-topics
bun run telegram:package-group:accounting
| Surface slug | Group title | Forum topics |
|---|---|---|
hq |
TOC Ops · HQ | alerts · day-ops · aar · identity |
ash-staging |
TOC Ops · ASH · staging | plays · balances · onboard · alerts |
all-accounting |
TOC Ops · Accounting | Deposits · Withdrawals · Reconcile |
sandbox |
TOC Ops · sandbox | scratch · experiments |
Partners post proof in their package forum · Accounting topic. Ops may mirror in all-accounting (separate supergroup).
TELEGRAM_SURFACES key pkg-{code} binds the chat id only — topic routing for package forums comes from reports/telegram/forums/{CODE}.json, not from house TELEGRAM_TOPICS.
Catalog research agent — compares catalog + live forum metadata and proposes enhancements (icons, pinned templates, missing topics):
bun run telegram:catalog:research # write reports/telegram/catalog-enhancements.json
bun run telegram:catalog:research --json # stdout only
bun run telegram:catalog:research --llm # optional LLM pass (OPENAI_API_KEY)
Code: lib/telegram/catalog-research/ · schema factorywager.telegram-catalog-enhancement.v1
Daily cron (Reasonix / OS Bun.cron — primary):
bun run telegram:catalog:research:cron:preview # next fire times + system TZ
bun run telegram:catalog:research:cron:register # launchd @ 07:00 system local
bun run telegram:catalog:research:once # manual tick (structured JSON report)
bun run telegram:catalog:apply-enhancements # merge safe changes → catalog-overrides.json
bun run telegram:catalog:research:cron:remove # uninstall
Worker pipeline: load catalog → gather Bot API signals → deterministic + optional LLM → catalog-enhancements.json.
Env: TELEGRAM_CATALOG_RESEARCH_TZ=America/New_York · TELEGRAM_CATALOG_RESEARCH_APPLY_SAFE=1 (auto-merge icon metadata) · OPENAI_API_KEY (LLM pass). macOS logs: /tmp/bun.cron.telegram-catalog-research.stdout.log.
| Concept | Example | Notes |
|---|---|---|
| Partner code | ASH |
Package / parent — used by package-group CLIs only |
| Call-sign seat | ASH-001 |
Agent seat — used by onboard CLI only |
| Package group title | TOC Ops · ASH · Ash Ops |
TOC Ops · {CODE} · {DisplayName} — display from partner parent name |
CLI ref rule (strict — no cross-acceptance):
| Command | Ref | Example |
|---|---|---|
onboard-partner-package.ts |
call-sign | BIL-001 |
package-group-create-forum |
partner CODE | BIL |
package-group-wire |
partner CODE | BIL |
link-package-group |
partner CODE | BIL |
Passing BIL to onboard or BIL-001 to create-forum fails with a redirect message.
House ops groups (TOC Ops · HQ, TOC Ops · ASH · staging, TOC Ops · sandbox) are separate surfaces — see telegram-factory.md.
1. bun tools/onboard-partner-package.ts ASH-001 --create-package-group
→ reports/telegram/pending-package-groups.jsonl
→ prints operator recipe (create forum + add @TOC_Op_bot)
2. Operator creates Telegram forum with exact title; copies chat_id + invite link
3. bun run telegram:ops -- link-package-group ASH -100… --invite https://t.me/+…
→ package_group_registry upsert
→ optional “package room ready” DM to linked seat
Path: reports/telegram/pending-package-groups.jsonl (append-only).
{
"action": "create_package_group",
"partner_code": "ASH",
"display_name": "Ash Ops",
"suggested_title": "TOC Ops · ASH · Ash Ops",
"requested_by": "ASH-001",
"tree_node_id": "<uuid>",
"timestamp": "2026-07-26T20:00:00.000Z"
}
CREATE TABLE IF NOT EXISTS package_group_registry (
partner_code TEXT PRIMARY KEY,
chat_id TEXT NOT NULL,
invite_link TEXT,
title TEXT NOT NULL,
requested_by TEXT,
created_at TEXT NOT NULL,
linked_at TEXT NOT NULL
);
--create-package-groupOn tools/onboard-partner-package.ts:
bun tools/onboard-partner-package.ts ASH-001 --create-package-group
bun tools/onboard-partner-package.ts ASH-001 --dry-run --create-package-group
Resolves partner_code from seat call-sign prefix or parent partner node. Appends JSONL even on dry-run (when not --dry-run only for writes? Plan says emit on flag - dry-run should still print recipe but maybe skip JSONL append on dry-run - I’ll skip JSONL on dry-run to avoid polluting pending file).
Actually plan says: bun tools/onboard-partner-package.ts ASH-001 --create-package-group - dry-run with –dry-run might skip JSONL. I’ll skip JSONL append when --dry-run is set.
link-package-groupbun run telegram:ops -- link-package-group ASH -1003937534779
bun run telegram:ops -- link-package-group ASH -1003937534779 --invite 'https://t.me/+…'
bun run telegram:ops -- link-package-group ASH -1003937534779 --no-dm
bun run telegram:ops -- link-package-group ASH -1003937534779 --requested-by ASH-001
| Flag | Effect |
|---|---|
--invite <url> |
Store invite; include in DM |
--no-dm |
Skip package-room DM |
--no-ack |
Skip ack_package_group_linked JSONL append |
--requested-by <call-sign> |
Audit trail + prefer that seat’s telegram_id for DM |
designate-dm-seat (post-link seat ack)Acknowledge which seat receives package-room DMs after a package_group_registry row exists (via link-package-group). Does not require tree_nodes.telegram_id yet — only a registry row + active seat.
bun run telegram:ops -- designate-dm-seat NOV NOV-001
bun run telegram:ops -- designate-dm-seat PAT PAT-001 --force
SSOT: package_group_registry.requested_by. Appends ack_dm_seat_designated to JSONL:
{
"action": "ack_dm_seat_designated",
"partner_code": "NOV",
"call_sign": "NOV-001",
"designated_by": "factory",
"timestamp": "2026-07-26T20:00:00.000Z"
}
Welcome DM + bot commands stay blocked until:
bun tools/telegram-link-chat.ts NOV-001 <telegram_user_id>
link-package-group … --requested-by NOV-001 also designates + JSONL acks (use --no-dm when telegram not ready).
DM seat status (assessPackageGroupDmSeat):
| Status | Meaning |
|---|---|
none |
No requested_by on registry |
designated |
Seat acknowledged; operator telegram pending |
linked |
Telegram id linked to designated seat (primary owner) |
shared |
Telegram id linked but owned by another call-sign; designated seat in ChatChannelMeta.callSigns — use /seat CODE-001 to switch bot context |
Seat map (all seats ↔ telegram ↔ package forum): bun run telegram:ops -- seat-map
bun run telegram:handshake:readiness # summary table
bun run telegram:handshake:readiness NOV --detail # per-lane audit (--detail alone enables deep lanes)
bun run telegram:handshake:verify NOV --live # live Telegram title match
Flags: [CODE…] · --detail · --deep (optional; --detail alone runs deep lanes) · --live · --json · --db <path>
| Phase | Meaning |
|---|---|
blocked |
Missing registry row or forum metadata gap |
forum_ready |
Registry + forum metadata OK; DM seat not designated |
designated |
Seat acknowledged in registry; operator telegram pending — outbox routing tracked in gaps/deep lanes, not a phase gate |
operator_ready |
Linked telegram and handshake verify OK — welcome DM + /status + play callbacks |
Exit 1 only when any row has phase blocked (not for designated or invite-pending membership).
Deep lanes (--detail): forum · audit (JSONL) · routing (outbox thread map) · operator (blocked-until-link explicit). Audit lane jsonl_dm_designated passes when an ack_dm_seat_designated line exists or when create_package_group.requested_by matches registry requested_by (fallback when designate ran via link without a separate ack line).
Every package forum is expected to contain at least three actors once fully staffed:
| # | Actor | Notes |
|---|---|---|
| 1 | Factory bot | @TOC_Op_bot — administrator |
| 2 | House operator | Your personal Telegram — creates / owns the group |
| 3 | Partner / agent | Human for the designated seat (e.g. NOV-001) — joins via invite |
More humans (experts, observers) increase the count — that is normal.
getChatMemberCount (via handshake:desk --refresh or directory --refresh) is a cheap sanity check:
| Count | Interpretation |
|---|---|
1 |
Understaffed — re-check bot admin |
2 |
Bot + house only — normal while partner not in forum or telegram not linked |
3 |
Bot + house + partner — typical steady state (3·OK) |
4+ |
Core trio + extras — desk cell N·ext (e.g. 4·ext, 5·ext) |
! suffix (e.g. 2·house!): operator telegram is linked but partner has not joined the forum yet — send the registry invite (expect 3·OK after join). Deep lane forum_members fails at 2·house! until partner joins.
Code: lib/telegram/package-group-membership.ts · desk column MEMBERS · readiness MEM · deep lane forum_members.
2·house!)Operator DM linked but partner not in forum yet:
bun run telegram:handshake:invite-gap
bun run telegram:handshake:invite-gap ASH BIL --refresh
bun run telegram:handshake:desk --invite-gap
bun run telegram:handshake:readiness --invite-gap
bun run telegram:ops -- send-forum-invite NOV
bun run telegram:ops -- send-forum-invite --all
bun run telegram:handshake:invite-gap --send --dry-run
Flags: [CODE…] · --refresh · --json · --send · --dry-run · --force · --db <path>
Exit 1 when gaps exist (scriptable). Readiness exits 1 only on phase blocked. After partner joins → 3·OK.
Send path appends ack_forum_invite_sent to package group JSONL. Deep lanes: forum_invite_gap · jsonl_forum_invite_sent. Code: lib/telegram/forum-invite-gap.ts.
Read-only on Pages — no live Telegram API. Data flows CLI → SQLite → snapshot bake → static JSON → portal panels.
assessHandshakeReadiness({ deep: true }) (handshake-readiness.ts)
→ exportTelegramHandshakeSnapshot + exportTelegramHandshakeCatalog (handshake-snapshot.ts)
→ public/registry/telegram-handshake.json + telegram-handshake-catalog.json
→ ops-summary.telegramHandshake (buildOpsSummary / ops:snapshot)
→ public/registry/ops-summary.json + static.json
→ portal panels (fetch same-origin JSON)
| Surface | URL | What it shows |
|---|---|---|
| Full Ops panel | /portal/ops/ |
Invite-gap metric, verify/lanes columns, expandable row details (gap · invite link · blocked lanes · next steps), gaps-only filter, CLI hints, catalog link |
| Executive dashboard | /portal/dashboard/ |
Handshake plane card — invite gaps · operator_ready · blocked · link to ops panel |
| Monitoring | /monitoring/ |
TG invite gaps card + proof tile → ops panel |
| Health | /portal/health/ |
Loads ops-summary (enriched TOC/loop; handshake via ops slice) |
| Registry artifact | /registry/telegram-handshake.json |
Full factorywager.telegram-handshake.v1 rows (verify, lanes, nextSteps) + CLI hints |
| Catalog artifact | /registry/telegram-handshake-catalog.json |
Machine SSOT — lanes, CLI flags, constants (telegram:handshake:catalog) |
| Portal weave | /registry/portal-weave.json |
Links handshake + catalog artifacts in registry index |
| TOC board | /portal/toc/ |
Partner profile telegram lanes (Soft fixture) — not package-group handshake desk |
Refresh bake after CLI changes:
bun run telegram:handshake:desk --refresh
bun run ops:snapshot # telegram-handshake.json + catalog + ops-summary slice
bun run serve:public:hot # local portal
Machine reference (constants / CLI flags): bun run telegram:handshake:catalog
| Condition | Behavior |
|---|---|
| Unknown call-sign / tree node | Throw on resolve |
Cannot derive partner_code |
Throw with hint to set parent partner or valid call-sign |
Invalid partner_code on link |
Exit 1 (^[A-Z]{3,6}$ — handshake-ref.ts) |
Invalid chat_id |
Exit 1 (numeric Telegram id) |
No DM + no --no-dm |
Upsert registry; skip DM quietly |
| No invite + DM requested | Upsert registry; DM without link text |
TOC Ops · {CODE} · {DisplayName} (from JSONL suggested_title).@TOC_Op_bot as administrator (Manage Topics when forum topics are needed).chat_id (e.g. -100…) and invite URL.link-package-group.After factory emits JSONL, ct consumes pending lines and guides the operator (create remains human):
bun run ct package-group-pending [--path ../reports/telegram/pending-package-groups.jsonl]
bun run ct package-group-wire ASH --chat tg:chat:-100… [--invite-create] --apply
Soft writes partners.telegram_ref; prints factory link-package-group handoff + allowlist CSV (manual .env paste).
Use --apply --ack on wire to append ack_package_group_wired and remove the partner from package-group-pending.
| Action | Plane | When |
|---|---|---|
create_package_group |
Factory | --create-package-group |
ack_package_group_wired |
Soft | package-group-wire --apply --ack |
ack_package_group_linked |
Factory | link-package-group (default) or acknowledge-pending |
ack_dm_seat_designated |
Factory | designate-dm-seat or link-package-group --requested-by |
Open pending = latest create_package_group per code without a matching ack_package_group_wired. The JSONL file is never rewritten — only appended.
bun run ct package-group-wire ASH --chat tg:chat:-100… --apply --ack
bun run telegram:ops -- link-package-group ASH -100… --invite '…'
bun run telegram:ops -- acknowledge-pending ASH
Future: MTProto auto-create — implemented (Decision #52). See toc-ops-repo/docs/system/TELEGRAM.md § MTProto user lane.
reports/telegram/forums/{CODE}.json)Written by MTProto package-group-create-forum, Bot API package-group-forum-enhance, or backfilled for manual forums:
cd toc-ops-repo
bun run forum-metadata-backfill ASH PAT NOV BIL --apply --factory-db ../data/operations.db
# Factory Bot API — icon + Ops/Alerts/Liquidity/Outs/Accounting topics
bun run telegram:package-group:enhance ASH --ensure-topics
bun run telegram:package-group:accounting # all linked partners
bun run telegram:package-group:enhance SPEN --accounting-prompt
Factory verify reads metadata when present (forum_metadata check in verify-package-group-handshake.ts).
Topic plan: see Forum topic plans (SSOT) above — five topics, identical titles per partner.
| Field | Notes |
|---|---|
chatId / chatRef |
Must match registry + wired ack |
topics |
Five-topic plan above; topicsComplete when every thread id is set |
topicsThreadMap |
Lowercase title keys — e.g. liquidity/outs, accounting |
accountingPromptMessageId |
Set when Accounting bootstrap prompt posted once |
accountingPromptPostedAt |
ISO timestamp of accounting prompt |
iconUploaded |
true when Bun.Image JPEG uploaded via MTProto EditPhoto |
backfilled |
true for manual forums without MTProto branding |
Accounting bootstrap — @TOC_Op_bot needs Manage Topics to create the Accounting thread via Bot API. When missing, create the topic manually and post:
bun run seat:desk:accounting-prompt SPEN-001 --thread-id 37 --post
Every desk publish/refresh also tries ensurePartnerForumAccounting (non-blocking). See seat-capital-desk.md.
Validate the full lifecycle before production use:
| Step | Command | Pass criteria |
|---|---|---|
| 1 Emit | bun tools/onboard-partner-package.ts ASH-001 --create-package-group |
JSONL create_package_group; title matches formatPackageGroupTitle |
| 2 Pending | bun run ct package-group-pending --path reports/telegram/pending-package-groups.jsonl |
ASH listed with operator recipe |
| 3 Human | Create forum with exact suggested_title; add @TOC_Op_bot admin |
Manual (Bot API cannot create groups) |
| 4 Soft wire | bun run ct package-group-wire ASH --chat tg:chat:-100… --apply --ack |
ack_package_group_wired; pending list empty for ASH |
| 5 Factory link | bun run telegram:ops -- link-package-group ASH -100… --invite '…' |
package_group_registry row; ack_package_group_linked |
| 6 Verify | bun run telegram:handshake:verify ASH |
All checks green incl. forum_metadata when backfilled (exit 0) |
To skip human forum create when a house surface already exists (e.g. ash-staging -1003937534779):
bun run ct package-group-wire ASH --chat tg:chat:-1003937534779 --apply --ack
bun run telegram:ops -- link-package-group ASH -1003937534779
bun run telegram:handshake:verify ASH
Do not use staging chat_ids as production package forums.
Soft plane note: ct package-group-wire --apply requires partner ASH in toc-ops.sqlite. Use bun scripts/bootstrap-handshake-partner.ts CODE --confirm-operational on operational data/toc-ops.sqlite, or demo seed on /tmp/toc-ops-demo.sqlite (see below).
Soft demo DB (isolated wire test):
cd toc-ops-repo
bun run src/db/migrate.ts --demo-reset --db /tmp/toc-ops-demo.sqlite
TOC_OPS_DB=/tmp/toc-ops-demo.sqlite bun run src/db/seed.ts --db /tmp/toc-ops-demo.sqlite
TOC_OPS_DB=/tmp/toc-ops-demo.sqlite bun run ct package-group-wire ASH \
--chat tg:chat:-1003937534779 --apply --ack \
--path ../reports/telegram/pending-package-groups.jsonl
Operational Soft DB (data/toc-ops.sqlite):
cd toc-ops-repo
bun scripts/bootstrap-handshake-partner.ts ASH --confirm-operational \
--display-name "Cascade Partner" \
--wire --chat tg:chat:-1003937534779 --ack \
--path ../reports/telegram/pending-package-groups.jsonl
bun run ct package-group-wire ASH --chat tg:chat:-1003937534779 --apply --ack \
--path ../reports/telegram/pending-package-groups.jsonl
Requires --confirm-operational once (Decision #36). Creates operational partner row + optional wire; no demo seed_profile.
# Handshake lifecycle
bun run test:telegram-handshake
bun run telegram:handshake:verify ASH
bun run telegram:handshake:verify ASH --json
bun run telegram:handshake:verify ASH --live
# Flags: --path <jsonl> · --forums-dir <dir> · --db <path> · --json · --live
# Unified desk (registry + known chats + handshake)
bun run telegram:handshake:desk
bun run telegram:handshake:desk ASH PAT --refresh --live --detail
bun run telegram:handshake:desk --json --invite-gap
# Flags: --refresh · --live (implies --refresh) · --invite-gap · --detail · --json · --path <jsonl> · --db <path>
# Phased readiness + invite gap
bun run telegram:handshake:readiness
bun run telegram:handshake:readiness NOV --detail --live --json
bun run telegram:handshake:invite-gap --refresh
# readiness: --detail · --deep · --live · --json · --db
# invite-gap: --refresh · --json · --db
# Seat map
bun run telegram:ops -- seat-map
# Broadcast queue
bun test tests/ops-channel-outbox.test.ts tests/telegram-broadcast.test.ts
bun run telegram:ops -- send --all --queue --preview "hello "
bun run telegram:ops:consume -- --preview
partner_code ≠ call-sign (ASH staging)suggested_title byte-for-byte (setChatTitle on -1003937534779)package_group_registry row for ASH with correct chat_id--invite + linked telegram_id present (ASH-001 → 8013171035)tree_nodes.telegram_id unchanged (still DM)telegram_ref)-1004464761699) · verify 9/9 live · desk OKOptional Soft automation (Decision #52): bun run package-group-create-forum CODE in toc-ops-repo when MTProto session configured — see toc-ops-repo/docs/system/TELEGRAM.md § MTProto App Registry + bootstrap troubleshooting.
bun run telegram-user-health → OK in toc-ops-repocreateChat / createChatInviteLink from factorypackage-group-create-forum when session set; manual UI otherwise)