Message definitions live in proto/postflowx/**.proto (source of truth; drafts today — finalized while building Steps 3, 8–12; frozen after Step 12). This file holds the rules around them. Structure: ARCHITECTURE.md.
Not decided yet (by design): the IPC request envelope. Its exact shape (request id, sender, target, operation, payload, deadline, actor, idempotency key, error model, streaming) is designed in ROADMAP Step 3a and then written into this file and
proto/. Everything below defines the rules that envelope must serve.
| Channel | Transport | Services / content |
|---|---|---|
| web → Gateway | gRPC / TCP / mTLS | operations by domain: auth, users, plugins (admin), approvals, accounts, media, posts, jobs, system (added per step; packages postflowx.<domain>.v1) |
| process ↔ Gateway ↔ process | gRPC / UDS (envelope from Step 3a) | typed operations: DB ops (served by the Gateway), fs.* (File-System), plugins.* (PMP), executioner.* (jobs, plugin control, approvals), watchdog.* (status), health |
| Executioner ↔ plugin | private inherited channel | plugin.v1.PluginService (Executioner → plugin) and host.v1.HostService (plugin → Executioner) |
| Controller ↔ Watchdog | private socketpair | controller.v1: RestartProcess, StopProcess, GetStatus + heartbeat (static platform-process list only) |
| Watchdog → Gateway health | status-only socket | grpc.health.v1.Health of the Gateway (it cannot be asked through itself) |
- web↔Gateway: internal CA from
init-secrets.sh, server SANserver, clientCN=postflowx-web;productionmode refuses to start without mTLS. Inside the server: each process has its own socket dir (only that UID + the Gateway UID can enter) — the socket a message arrives on is the sender identity. - The Gateway assigns the sender, strips any identity fields supplied by callers, and checks the route table (deny by default). User identity is never read from a caller-controlled field: web sends an opaque session token, the Gateway resolves the actor; plugins can never name a user (§3).
- Initial route table (finalized in Step 3a):
web→ Gateway-served ops (auth/users/plugins/approvals/accounts/media/posts/jobs/system) ·executioner→fs.*,plugins.*, DB ops, approvals,healthreplies ·pmp→ DB ops (+fs.*if needed) ·filesystem→ DB ops (metadata) ·watchdog→healthof every process, status publish ·controlleris not a Gateway client. - Limits (defaults): message 4 MiB (files: chunks of 64 KiB); deadline 30 s unary,
Publish120 s default / 10 min max; per-sender rate + concurrency limits. Invalid identity, unknown route, unknown major version or malformed message → rejected. - Versioning: package carries the major (
…v1); only additive changes inside a major; breaking = newv2served in parallel. Plugins declareapi_compatibility; the registry refuses incompatible ones. Contract version = deploy-repo tag.
Every user-scoped work item (connect, callback, validate, publish, reconcile, per-account task) is initiated by the Executioner, which creates a call context: call_id → (plugin, user, purpose, optional job/account, expiry ≤ call deadline). The plugin echoes call_id on every request; the Executioner looks up the context, attaches the actor when forwarding to the Gateway, and rejects unknown, expired or foreign call_ids. Disabling a plugin or revoking a grant drops its contexts. Plugin-global operations need only the plugin identity + a grant.
Every call is first rights-checked by the Executioner (§5), then forwarded.
| Group | Operations | Notes |
|---|---|---|
| Storage | StorageGet/Put/Delete/List |
scope USER (owner from the call context) or PLUGIN; Put with expected_version; value ≤ 256 KiB; per-plugin quota |
| Accounts | UpsertAccount, DeleteAccount, SetAccountStatus |
only for the context's user + this plugin |
| Credentials | StoreCredential, LeaseCredential |
lease returns plaintext + lease_expires_at; audited (plugin, user, account, purpose) |
| Media | OpenMedia (stream), CreatePublicMediaUrl |
only media of the current job; served by File-System; TTL capped |
| Jobs | CheckpointAttempt |
opaque bytes, size-limited, job-scoped context only |
| Audit | AppendAudit |
actor/plugin set by the platform; no secrets |
| Plugin trust level | Capability granted? | Result |
|---|---|---|
trusted |
yes | forwarded silently |
trusted |
no | PERMISSION_DENIED |
untrusted |
yes | held → approval needed |
| any | not granted | PERMISSION_DENIED |
Approval is fully local (no external server, no network): the Executioner creates an approval entry (approvals.create, stored in the local DB: plugin, capability, summary of the request, call context, time). The admin answers deny / allow once / allow always for this capability via the web UI (the BFF follows approvals.pending through the Gateway and pushes to the browser with SSE) or via postflowx-admin approvals list|allow|deny. The decision is stored and audited and releases or rejects the held request. Timeout 60 s = deny. Prompts are rate-limited per plugin. Only users with the admin role may answer.
Publish(job_id, attempt_id, idempotency_key, account, post, checkpoint?) → outcome PUBLISHED | RETRYABLE_FAILURE | RATE_LIMITED | AUTH_FAILURE | PERMANENT_FAILURE | UNKNOWN + optional external_id/url, stable error_code, safe user-presentable error_message, retry_after. Plugins must be idempotent per attempt_id, honor the deadline, never log secrets. The Executioner decides retries/backoff/terminal states (default backoff 1 min → 5 min → 30 min → 2 h, max 5 attempts, configurable); plugins never schedule retries. Reconcile(job, checkpoint) → PUBLISHED | NOT_PUBLISHED | UNDECIDABLE. Never auto-retry after a timeout/crash/UNAVAILABLE that occurred after dispatch.
Standard gRPC status + google.rpc.ErrorInfo (domain="postflowx", stable reason). No stack traces, SQL errors, secrets or paths to untrusted callers.
| Status | Reasons (examples) | Retry |
|---|---|---|
INVALID_ARGUMENT |
VALIDATION_ERROR |
no |
UNAUTHENTICATED |
SESSION_INVALID, SENDER_UNKNOWN |
no |
PERMISSION_DENIED |
FORBIDDEN, ROUTE_NOT_ALLOWED, CAPABILITY_NOT_GRANTED, APPROVAL_DENIED, APPROVAL_TIMEOUT, CALL_ID_INVALID |
no |
NOT_FOUND |
absent or hidden | no |
ABORTED/FAILED_PRECONDITION |
CONFLICT, VERSION_MISMATCH, STATE_INVALID |
after re-read |
RESOURCE_EXHAUSTED |
RATE_LIMITED, QUOTA_EXCEEDED |
later |
DEADLINE_EXCEEDED |
may have completed | query state first |
UNAVAILABLE |
NOT_READY, DEPENDENCY_DOWN |
reads yes; mutations: query state first |
UNIMPLEMENTED |
UNSUPPORTED_PROTOCOL |
no |
INTERNAL |
no details | no |
Mutations where a duplicate would do harm take an idempotency key; the Gateway stores (sender, key) → result for a bounded time. A timed-out request may have completed — query before repeating. The Gateway never silently replays non-idempotent calls.
<id>/<version>/ main.json · code/<prebuilt executable> · assets/icon.svg (optional) · sha256sums (written by PMP)
{ "manifest_version": 1, "id": "com.postflowx.instagram", "name": "Instagram", "version": "0.1.0",
"provides": ["publisher"], "api_compatibility": { "min": "1.0", "max_exclusive": "2.0" },
"entrypoint": { "executable": "code/postflowx-plugin-instagram" },
"capabilities_requested": ["storage.read","storage.write","accounts.manage","credentials.store","credentials.lease","media.read","media.public_url","audit.append"],
"callbacks": ["oauth"],
"tasks": [{ "name": "refresh_account", "scope": "account", "interval": "24h" }],
"network": { "egress_hosts": ["graph.facebook.com", "graph.instagram.com"] },
"resources": { "memory_mb": 256, "max_processes": 16 } }- The manifest is input, not a security boundary. PMP validates strictly (reverse-DNS lowercase ID, version syntax, known capabilities, task definitions, sizes; unknown fields rejected). Requested capabilities are only requests — the admin grants (reviewable, revocable, audited). Unknown capability = fail closed.
egress_hostsis informational in v1 (not enforced: shared network namespace).resourcesapplied as rlimits best-effort.tasksare invoked by the Executioner with the account owner's context; plugins register no timers.- Capabilities (v1):
storage.read/write,accounts.manage,credentials.store,credentials.lease,media.read,media.public_url,audit.append. No "all access", no DB capability. Kindpublisheronly; laterai_text. - Open (N-6): this assumes
code/contains a prebuilt executable. If the server should compile plugins from source, PMP and the server image change (see DECISIONS §3).
- Isolation (Executioner applies, Controller granted the means): own UID/GID, no capabilities,
no_new_privs, non-dumpable, clean env, private tmpfs, rlimits (NPROC, NOFILE, memory, CORE=0), private channel only, read-only plugin dir. Verified after every spawn (/proc/<pid>/status). Not provided in v1: egress filtering, protection from a malicious plugin with legitimately granted capabilities. - Trust levels:
trusted(requests pass if granted) anduntrusted(every request needs approval). Set by the admin locally; hash pinning at install detects tampering. Grants are always explicit. - Declarative UI:
Describereturns name, icon (SVG sanitized, size-limited), connect flow (OAUTH2_REDIRECT | FORM | NONE), settings schema, post options schema, media rules, caption limit, API version. Schemas = restricted JSON-Schema subset (stringwithenum/maxLength/pattern,integer,number,boolean, arrays of those, nestedobjectdepth ≤ 2), validated at registration. Frontend owns the composer; the Gateway validates the final post against the schema, the plugin addsValidatePost. - Data: only via the storage API; uninstall requires explicit retain/delete.
- Lifecycle states:
DISCOVERED → VALIDATING → INSTALLED → ENABLED → STARTING → READY; failuresINVALID, INCOMPATIBLE, START_FAILED, UNHEALTHY, CRASH_LOOP, DISABLED, UNINSTALL_PENDING. - Updates (v1): operator installs a new version with the CLI; admin switches; capability changes need renewed approval. No remote auto-install.
- SDK (
postflowx-server/sdk/, Step 12d): generated stubs, service skeleton, call-context handling, Host API client, health service; demo plugin with failure injection; manifest validator.
Valid call; no/invalid cert; wrong sender for a route; forged identity fields; oversized message; expired deadline; unsupported version; unknown/expired/foreign call_id; plugin calling a route it has no channel to; untrusted request without approval / with deny / with timeout; raw SQL attempt; malformed bytes.