RFC-0005: configuration hierarchy and post lifecycle
Version: 0.2.2 | Status: normative | Phase: impl
1. Summary
[RFC-0005:C-SUMMARY] Summary (Informative)
This RFC defines the configuration hierarchy and post lifecycle management for typub.
Scope:
- Three-layer configuration system (global, per-content, platform-specific) with 5-level resolution
- Resolution order for configuration values
- Draft/publish lifecycle states and transitions
- Platform capability declarations for lifecycle support
- Status tracking schema for remote state
Out of scope:
- Platform-specific API details (covered by adapter implementations)
- Content rendering pipeline (covered by RFC-0002)
Rationale: As typub supports more platforms with varying draft/publish capabilities, a unified abstraction is needed to:
- Allow per-content override of global settings
- Declare platform capabilities in a type-safe manner
- Track remote lifecycle state for correct transition handling
- Provide consistent behavior across platforms with different API models
Since: v0.1.0
2. Specification
[RFC-0005:C-CONFIG-LAYERS] Configuration Layers (Normative)
typub MUST support a three-layer configuration system:
-
Global configuration (
typub.toml): Project-wide defaults that apply to all content items unless overridden. -
Per-content configuration (
meta.toml): Content-specific settings that override global defaults for that content item. -
Platform-specific configuration: Both global and per-content configurations MAY include platform-specific sections.
Configuration File Locations
- Global configuration:
typub.tomlat the project root. - Per-content configuration:
meta.tomlwithin each content directory.
The directory containing typub.toml defines the project root. See RFC-0005:C-PROJECT-ROOT for path resolution semantics.
Configuration Structure
Global configuration (typub.toml):
# Global defaults (layer 4)
published = true
# Global platform-specific (layer 3)
[platforms.hashnode]
published = false
[platforms.devto]
published = true
Per-content configuration (meta.toml):
title = "My Post"
created = "2026-01-15"
# Per-content default (layer 2)
published = false
# Per-content platform-specific (layer 1)
[platforms.hashnode]
published = true
Rationale: This layered approach allows users to set sensible defaults while retaining fine-grained control. The three-layer system (global, per-content, platform-specific) combined with nested platform sections creates a 5-level resolution chain that covers all practical use cases without excessive complexity.
Since: v0.1.0
[RFC-0005:C-RESOLUTION-ORDER] Resolution Order (Normative)
For any configuration key, the implementation MUST resolve values using a 5-level fallback chain:
meta.toml→[platforms.<platform_id>].<key>— per-content platform-specificmeta.toml→<key>— per-content defaulttypub.toml→[platforms.<platform_id>].<key>— global platform-specifictypub.toml→<key>— global default- Adapter-defined default value
The first non-null value found MUST be used. If no value is found at any level, the adapter-defined default MUST apply.
Resolution Algorithm
#![allow(unused)]
fn main() {
fn resolve<T>(
content_meta: &Meta,
platform_id: &str,
global_config: &Config,
adapter_default: T,
key: impl Fn(&PlatformConfig) -> Option<T>,
global_key: impl Fn(&Config) -> Option<T>,
content_key: impl Fn(&Meta) -> Option<T>,
) -> T {
// Layer 1: per-content platform-specific
content_meta.platforms.get(platform_id).and_then(&key)
// Layer 2: per-content default
.or_else(|| content_key(content_meta))
// Layer 3: global platform-specific
.or_else(|| global_config.platforms.get(platform_id).and_then(&key))
// Layer 4: global default
.or_else(|| global_key(global_config))
// Layer 5: adapter default
.unwrap_or(adapter_default)
}
}
Example: Resolving published for Hashnode
Given:
meta.toml: no[platforms.hashnode]section,published = falseat top leveltypub.toml:[platforms.hashnode].published = true, no globalpublished- Adapter default:
true
Resolution:
- Check
meta.toml[platforms.hashnode].published→ not found - Check
meta.toml.published→ found:false→ use this value
Result: published = false
Example: Resolving published for Dev.to
Given:
meta.toml: no relevant settingstypub.toml:[platforms.devto].published = false,published = trueat top level- Adapter default:
true
Resolution:
- Check
meta.toml[platforms.devto].published→ not found - Check
meta.toml.published→ not found - Check
typub.toml[platforms.devto].published→ found:false→ use this value
Result: published = false
Rationale: The 5-level resolution order follows the principle of specificity: more specific configurations override more general ones. Per-content settings always take precedence over global settings, and platform-specific settings take precedence over general settings within the same scope.
Since: v0.1.0
[RFC-0005:C-DRAFT-SUPPORT] Draft Support Capability (Normative)
Each platform adapter MUST declare its draft support capability using the DraftSupport enum:
#![allow(unused)]
fn main() {
pub enum DraftSupport {
/// Platform has no draft concept. Content is always published immediately.
None,
/// Same object with a status field that can be toggled.
/// `reversible` indicates whether publish -> draft transition is supported.
StatusField { reversible: bool },
/// Draft and published content are separate objects with different IDs.
/// Transition from draft to published requires a platform-specific mutation.
SeparateObjects,
}
}
The AdapterCapability struct MUST include a draft_support field:
#![allow(unused)]
fn main() {
pub struct AdapterCapability {
pub id: &'static str,
pub name: &'static str,
pub tags: CapabilitySupport,
pub categories: CapabilitySupport,
pub internal_links: CapabilitySupport,
pub draft_support: DraftSupport, // NEW
pub notes: &'static str,
}
}
Rationale: Different platforms have fundamentally different models for draft content:
- Some platforms (Dev.to, Ghost, WordPress, Confluence) use a status field on the same object
- Some platforms (Hashnode) maintain separate draft and published objects
- Some platforms (Notion) have no draft concept at all
Declaring this capability allows the pipeline to handle transitions correctly.
Since: v0.1.0
[RFC-0005:C-LIFECYCLE-TRANSITIONS] Lifecycle Transitions (Normative)
State transitions MUST follow platform-specific rules based on the platform type.
Platform Classification:
Platforms are classified into two categories:
- API-based platforms: Platforms with remote APIs (Hashnode, Dev.to, Ghost, WordPress, Confluence, Notion)
- Local output platforms: Platforms that produce local files or clipboard output (Astro, Xiaohongshu, Copypaste)
Local Output Platform Rules
For local output platforms, the lifecycle decision is trivial:
- The
publishedconfiguration MUST be ignored. - The implementation MUST always write/update the local output.
- No draft/publish lifecycle tracking is applicable.
- The implementation MAY store a status row for change detection (content_hash).
API-based Platform Rules
For API-based platforms, the implementation MUST apply the following decision logic.
Precondition check:
Before applying the decision table, the implementation MUST determine:
has_remote_object: Whetherplatform_idis present in the status rowremote_status: The stored lifecycle state (“draft” or “published”)desired_published: The resolvedpublishedconfiguration value per RFC-0005:C-RESOLUTION-ORDERdraft_support: The platform’s declaredDraftSupportcapability
Data integrity guard:
If has_remote_object = true and remote_status is not one of {"draft", "published"}:
- The implementation MUST fail with a diagnostic error.
- The error message MUST indicate the invalid
remote_statusvalue and the affected content/platform. - The implementation MUST NOT proceed with any API operation.
This guard ensures corrupted or incomplete status data does not cause undefined behavior.
Decision Table (API-based platforms only):
| has_remote_object | remote_status | desired_published | DraftSupport | Action |
|---|---|---|---|---|
| false | - | true | Any | Create as published |
| false | - | false | StatusField/SeparateObjects | Create as draft |
| false | - | false | None | Create as published (ignore config) |
| true | “published” | true | Any | Update existing published content |
| true | “published” | false | StatusField { reversible: true } | Update status to draft |
| true | “published” | false | StatusField { reversible: false } | Warn: cannot unpublish; update content only |
| true | “published” | false | SeparateObjects | Warn: cannot unpublish; update content only |
| true | “published” | false | None | Update published content (ignore config) |
| true | “draft” | true | StatusField | Update status to published |
| true | “draft” | true | SeparateObjects | Execute publishDraft mutation |
| true | “draft” | true | None | N/A (None never creates draft) |
| true | “draft” | false | StatusField/SeparateObjects | Update existing draft content |
| true | “draft” | false | None | N/A (None never creates draft) |
DraftSupport-specific Behaviors
DraftSupport::None (API-based):
- The
publishedconfiguration MUST be ignored. - Content MUST always be created/updated as published.
- The implementation MUST store
remote_status = "published"after each operation.
DraftSupport::StatusField:
- If
reversibleis true: Both draft-to-publish and publish-to-draft transitions are permitted. - If
reversibleis false: Only draft-to-publish is permitted; publish-to-draft MUST log a warning and update content without changing status.
DraftSupport::SeparateObjects:
- Draft-to-publish: MUST use platform-specific “publish draft” mutation.
- After successful publish, the implementation MUST:
- Update
platform_idto the new published object ID. - Update
remote_statusto “published”. - Update
urlto the public URL.
- Update
- Publish-to-draft: MUST NOT be supported; MUST log a warning and update content without changing status.
Rationale: Separating local output from API-based platforms eliminates data model confusion. The data integrity guard ensures corrupted status rows cause immediate, diagnosable failures rather than silent misbehavior.
Since: v0.1.0
[RFC-0005:C-STATUS-TRACKING] Status Tracking (Normative)
The status database tracks publish results and remote state.
Schema:
CREATE TABLE IF NOT EXISTS platform_status (
slug TEXT NOT NULL,
platform TEXT NOT NULL,
published INTEGER NOT NULL,
url TEXT,
platform_id TEXT,
published_at TEXT,
content_hash TEXT,
remote_status TEXT,
PRIMARY KEY (slug, platform)
);
Row Persistence Policy
API-based platforms: The implementation MUST store a status row for each content item created or updated on an API-based platform. This includes both draft creation/updates and published content creation/updates.
Local output platforms: The implementation MAY store a status row for change detection purposes. This is OPTIONAL; implementations that do not need content_hash-based change detection are not required to store rows for local output platforms.
Platform-specific Field Semantics
| Field | API-based platforms | Local output platforms |
|---|---|---|
platform_id | Remote object identifier (MUST be set) | NULL (no remote object) |
remote_status | “draft” or “published” (MUST be set) | NULL (lifecycle not applicable) |
url | Public URL or draft URL | Local output path or NULL |
content_hash | Content hash for change detection | Content hash for change detection |
PlatformStatus struct
#![allow(unused)]
fn main() {
pub struct PlatformStatus {
pub published: bool,
pub last_publish: Option<PublishResult>,
pub content_hash: Option<String>,
/// Remote lifecycle state (API-based platforms only).
/// - Some("draft"): Content exists as draft
/// - Some("published"): Content exists as published
/// - None: Local output platform (lifecycle not applicable)
pub remote_status: Option<String>,
}
}
Persistence Requirements
API-based Platforms
After each successful operation (create or update, draft or published), the implementation MUST store:
platform_id: The remote object identifierremote_status: “draft” or “published” matching the actual remote stateurl: The public URL (for published) or platform-specific draft URL (for draft)content_hash: Hash of the content at time of operation
For DraftSupport::SeparateObjects, after a draft-to-publish transition:
- The implementation MUST update
platform_idto the new published object ID. - The implementation MUST update
remote_statusto “published”. - The implementation MUST update
urlto the public URL.
Local Output Platforms
If the implementation chooses to store a status row:
platform_id: MUST be NULLremote_status: MUST be NULLurl: MAY be the local output pathcontent_hash: Hash of the content for change detection
Rationale: API-based platforms require state tracking for correct lifecycle transitions. The row persistence policy explicitly covers both draft and published operations to avoid ambiguity. Local output platforms have no remote state; row storage is optional and only useful for content_hash-based change detection.
Since: v0.1.0
[RFC-0005:C-PLATFORM-CAPABILITIES] Platform Capabilities (Informative)
This clause documents the draft support capabilities of each platform adapter.
API-based platforms:
| Platform | DraftSupport | Notes |
|---|---|---|
| Hashnode | SeparateObjects | Draft and Post are different objects with different IDs |
| Dev.to | StatusField { reversible: true } | Same endpoint, toggle published field |
| Ghost | StatusField { reversible: true } | Same endpoint, toggle status field |
| WordPress | StatusField { reversible: true } | Same endpoint, toggle status field |
| Confluence | StatusField { reversible: true } | Uses ?status=draft and publish via blueprint endpoint |
| Notion | None | No draft concept in API |
Local output platforms:
| Platform | DraftSupport | Notes |
|---|---|---|
| Astro | None | Local file output |
| Xiaohongshu | None | Local file output |
| Copypaste | None | Clipboard output |
Platform-specific notes:
- Hashnode: Requires
publishDraftmutation for draft-to-publish. Draft IDs become invalid after publishing. - Confluence: Drafts accessed via
?status=draftquery parameter. Publish viaPUT /wiki/rest/api/content/blueprint/instance/{draftId}. - Notion: Pages are created directly; no draft workflow in the API.
Since: v0.1.0
[RFC-0005:C-PROJECT-ROOT] Project Root Definition (Normative)
The project root is the directory containing typub.toml.
All relative paths stored in the status database (status.db) MUST be resolved relative to the project root.
The system MUST reject paths that resolve outside the project root with a descriptive error.
Path normalization for storage: When persisting paths to the status database:
- Absolute paths MUST be converted to relative paths (relative to project root).
- If the path cannot be expressed as relative to the project root (i.e., the asset is outside the project tree), the system MUST reject the operation with an error.
- Relative paths MUST use forward slashes (
/) as path separators, regardless of operating system. - Relative paths MUST NOT begin with
./or contain..components after normalization.
Path resolution on read: When reading paths from the status database:
- Relative paths MUST be resolved against the project root to obtain absolute paths for file operations.
- The resolved path MUST be validated to exist within the project root.
Rationale: Storing relative paths enables project portability — the entire project directory (including typub.toml, content, and .typub/status.db) can be moved, synced, or shared across machines without breaking path references.
Since: v0.2.0
[RFC-0005:C-RENDER-PREAMBLE] Typst Render Preamble Resolution and Merge (Normative)
typub MUST support an optional preamble configuration key for Typst render wrapper injection.
The preamble key MUST resolve using the standard 5-level resolution order defined in RFC-0005:C-RESOLUTION-ORDER:
meta.toml[platforms.<platform_id>].preamblemeta.toml.preambletypub.toml[platforms.<platform_id>].preambletypub.toml.preamble- Adapter-defined default (render config default)
Resolution semantics MUST use nullable presence (Option<String> equivalent):
- Missing value at a layer MUST be treated as
Noneand fall through. - Present value at a layer MUST be treated as
Some(value)and stop fallback.
During Render stage wrapper assembly, implementations MUST preserve adapter-specific preamble behavior and append resolved user preamble after adapter preamble when present:
final_preamble = adapter_preamble + "\n\n" + user_preamble
If no user preamble is resolved, adapter preamble MUST remain unchanged.
Rationale:
- Preserves existing platform-specific preamble contracts (for example Confluence and Xiaohongshu).
- Keeps user customization aligned with the unified configuration resolution model.
Since: v0.2.2
Changelog
v0.2.2 (2026-02-23)
Add Typst preamble resolution and merge semantics
Added
- Define optional preamble key with 5-layer resolution and adapter-preserving append behavior in Render stage
v0.2.1 (2026-02-22)
Terminology consistency for global config file
Added
- Replace stale config.toml references with typub.toml in C-RESOLUTION-ORDER
v0.2.0 (2026-02-13)
Add C-PROJECT-ROOT clause and rename config.toml to typub.toml
v0.1.0 (2026-02-12)
Initial draft