Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to dwarves-kit are documented here.

## [Unreleased]

### Added
- **`sync` one-way insert-only push to a foreign team board** (`notion-taskboard`
app, SPEC-003, implements ops-toolkit ID-138). Pushes a repo's board rows out
to a team-OWNED Notion board (create page, never update, never read for merge,
board file never written); fields are set only on page-create so team edits are
never overwritten, and the local sync-state map is the identity index. Status/
Priority/Weight map to the team board's own option names via `.kit.toml [sync]`
config, so the team schema is never mutated. The two-way mesh and its four live
adapters are untouched (`create_only` is a separate path, and the two-way
board parser stays `ID-`-only; only the one-way READ path accepts any
`[A-Z]+-\d+` prefix, e.g. dfoundation `DF-NN`, and it never mints or writes
board ids). The sink validates every mapped option against the target's
schema before any create, so it never auto-creates an option on the team
board; state is checkpointed after each create so a mid-batch failure never
re-pushes a page.

### Changed
- **`bridge` module folded into `sync`** (zero live consumers at fold time: no
`bridge=on` rows, no snapshot, module off). Legacy `mirror`/`status`/`writeback`
Expand Down
77 changes: 77 additions & 0 deletions docs/implementation-notes/oneway-create-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Implementation notes: one-way insert-only push (SPEC-003 / ID-138)

Delta from SPEC-003 only.

## 2026-07-18 create-only is a separate path, not a `plan_sync` flag

Context: the locked design is insert-only (never update after create). The
two-way `plan_sync` exists to MERGE, so bending it into strict create-only
would need a flag threaded through every branch, with blast radius on the four
live adapters.
Decision: added `plan_create_only` (pure) + `sync_create_only` (I/O) as a
parallel path, dispatched on `getattr(src, "create_only", False)`. The two-way
path is byte-for-byte unchanged.
Why: zero blast radius; the create-only planner is ~15 lines and trivially
proven with a negative control.
Alternatives: a `create_only` flag inside `plan_sync` (rejected: touches shared
merge logic used by 4 live adapters).
Impact: existing adapter tests unchanged; new logic is independently tested.

## 2026-07-18 the sink is write-only; `read()` returns `[]`

Context: truly one-way means we should not depend on reading the foreign team
board at all. The identity index is the local sync-state map (locked design).
Decision: `NotionTaskBoardSource.read()` returns `[]`; `plan_create_only`
consults `state["map"]` (not a read of the board) to know which rows are
already pushed. `ensure_binding()` still does ONE benign read to resolve the
data_source_id (required as the page-create parent), never a schema PATCH.
Impact: a team member deleting a card is NOT re-created (its bid stays in the
map); re-creating a deleted card is a v1.1 concern, out of the locked scope.

## 2026-07-18 status states the design left unmapped

Context: the locked field map names 5 states (queued/executing/parked/shipped
-> columns, dropped -> skip) but the board has 8 (claimed/speccing/validated
also exist).
Decision: NOT re-designed here. `skip_kw` defaults to `{"dropped"}` only; a
config `status_default` catches any state absent from the status map. If a row
in an unmapped state is reached with no default set, apply raises a guided
SystemExit rather than guessing a column.
Why: the kit ships no tenant policy; the consumer (dfoundation) decides where
claimed/speccing/validated land by setting `status_default` (or extending the
map). Honors "don't re-design" while never silently dropping active work.

## 2026-07-18 review findings applied (kit:code-reviewer + kit:advisor)

Two reviewers independently flagged the same landmines; fixes:
- **Partial-batch duplicates**: `apply()` now takes an `on_created(bid, rid)`
callback; `sync_create_only` checkpoints state after EACH create. A mid-batch
`ntn` failure no longer leaves created pages unrecorded (which would re-push
duplicates on a team board). Plus a `preflight(plan)` validates the whole
batch (status/priority/weight map + option existence) BEFORE any POST, so a
config error can't fail a run part-way. Preflight also runs before the
dry-run return, so `--dry-run` surfaces config errors with zero writes.
- **Select auto-create = silent schema mutation**: the "never mutate the team
schema" claim was false for select-type props (Notion auto-creates unknown
select options). Fixed by reading the schema in preflight and rejecting any
mapped option name not already on the board. This also auto-detects prop
TYPES from the schema (dropping the earlier "verify types manually" caveat).
- **Parser generalization was half-applied**: reverted `TITLE_RE` to `ID-`-only
and gave `parse_board` a `strict_id` flag (default True = two-way, ID-only).
The one-way path passes `strict_id=False`; `warn_duplicate_ids` gained the
same flag so a `DF-` board still gets dup warnings. Net: the two-way mesh is
byte-for-byte `ID-`-only again (its id-minting siblings stay consistent), and
only the create-only READ view widened.
- **Stale binding**: `ensure_binding` discards a cached binding whose `db_id`
differs from the configured target (a repointed `notion_taskboard_db` no
longer keeps pushing to the old board).
- Minor: dropped the unused `skip_statuses` ctor param and the dead
`notion_taskboard_intake` config key (a write-only sink has no intake path).

## 2026-07-18 tenant IDs stay in the consumer repo

Context: this repo (dwarves-kit) is public; the Task Board UUID and Han's
Notion person id are tenant identity.
Decision: the adapter takes db id / maps / owner as config; the dfoundation
`.kit.toml [sync]` carries the real values. No UUID in this repo (asserted by a
grep in the dfoundation PR, not here).
24 changes: 24 additions & 0 deletions lib/board/board.sh
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,30 @@ cmd_sync() {
v="$(kit_config_get sync.reminders_list "")"; [ -n "$v" ] && args+=(--list "$v")
v="$(kit_config_get sync.notion_db "")"; [ -n "$v" ] && args+=(--notion-db "$v")
v="$(kit_config_get sync.notion_parent "")"; [ -n "$v" ] && args+=(--notion-parent "$v")
# notion-taskboard: one-way, insert-only push to a foreign team board
# (SPEC-003). Down-filter only (a write-only sink has no intake path); the
# keys are TOML-friendly underscores but the engine's --filter app token
# keeps the hyphenated adapter name.
for fk in only_tags skip_tags; do
v="$(kit_config_get "sync.notion_taskboard_${fk}" "")"
[ -n "$v" ] && args+=(--filter "notion-taskboard:${fk}=${v}")
done
v="$(kit_config_get sync.notion_taskboard_db "")"
[ -n "$v" ] && args+=(--notion-taskboard-db "$v")
v="$(kit_config_get sync.notion_taskboard_status_map "")"
[ -n "$v" ] && args+=(--notion-taskboard-status-map "$v")
v="$(kit_config_get sync.notion_taskboard_status_default "")"
[ -n "$v" ] && args+=(--notion-taskboard-status-default "$v")
v="$(kit_config_get sync.notion_taskboard_priority_map "")"
[ -n "$v" ] && args+=(--notion-taskboard-priority-map "$v")
v="$(kit_config_get sync.notion_taskboard_weight_map "")"
[ -n "$v" ] && args+=(--notion-taskboard-weight-map "$v")
v="$(kit_config_get sync.notion_taskboard_owner "")"
[ -n "$v" ] && args+=(--notion-taskboard-owner "$v")
v="$(kit_config_get sync.notion_taskboard_props "")"
[ -n "$v" ] && args+=(--notion-taskboard-props "$v")
v="$(kit_config_get sync.notion_taskboard_types "")"
[ -n "$v" ] && args+=(--notion-taskboard-types "$v")
v="$(kit_config_get sync.hermes_target "")"; [ -n "$v" ] && args+=(--hermes-target "$v")
v="$(kit_config_get sync.hermes_home "")"; [ -n "$v" ] && args+=(--hermes-home "$v")
v="$(kit_config_get sync.multica_url "")"; [ -n "$v" ] && args+=(--multica-url "$v")
Expand Down
12 changes: 11 additions & 1 deletion lib/config/module-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ single-reader fence). No env vars; per-repo values live in `.kit.toml [sync]`.

| Env var | kit.toml key | Default | Status | Module | Doc |
|---|---|---|---|---|---|
| - | sync.apps | `""` (sync off) | [impl] | sync | Comma list of apps to sync to (`reminders,notion,hermes,multica`); the plugin mechanism. Legacy aliases `surfaces` and `sources` still read as fallbacks (renamed 2026-07-16 for plain vocabulary; `sources` also collided with SPEC-002's board-side inputs). |
| - | sync.apps | `""` (sync off) | [impl] | sync | Comma list of apps to sync to (`reminders,notion,hermes,multica,notion-taskboard`); the plugin mechanism. Legacy aliases `surfaces` and `sources` still read as fallbacks (renamed 2026-07-16 for plain vocabulary; `sources` also collided with SPEC-002's board-side inputs). `notion-taskboard` is a one-way, insert-only push (SPEC-003), not part of the two-way mesh. |
| - | sync.mode | `"manual"` | [impl] | sync | `manual` (default) or `cron`. Read by `lib/sync/deploy/macos/install` (kit ID-289): a repo must set `mode = "cron"` before that installer will render or load a per-repo scheduled-sync LaunchAgent; any other value is a clean refusal, not a silent fallthrough. Not read by `board.sh cmd_sync` itself -- manual `board sync` runs are unaffected by this key. ALSO re-read live by the installed `board-sync-cron` launcher on every scheduled run: flipping `mode` back to `"manual"` after install makes the next scheduled run skip cleanly (exit 0, logged) rather than silently keep syncing against a config that says it shouldn't. |
| - | sync.interval_secs | `3600` | [impl] | sync | Cron LaunchAgent `StartInterval` seconds, read by `lib/sync/deploy/macos/install` as the default cadence for `mode = "cron"`; `--interval-secs N` overrides it for one install run. |
| - | sync.reminders_list | `"Backlog"` | [impl] | sync | Apple Reminders list name. |
Expand All @@ -174,6 +174,16 @@ single-reader fence). No env vars; per-repo values live in `.kit.toml [sync]`.
| - | sync.multica_only_tags | `""` | [impl] | sync | Down-filter: a row must carry one of these tags to appear on multica. |
| - | sync.multica_skip_tags | `""` | [impl] | sync | Down-filter: a row carrying any of these tags never appears on multica. |
| - | sync.multica_intake | `""` (all) | [impl] | sync | Up-filter for foreign multica items: `all`, `tagged:<tag>`, or `none`. |
| - | sync.notion_taskboard_db | `""` | [impl] | sync | Target Notion database id for the one-way insert-only Task Board push (SPEC-003). Tenant id: lives in the consumer repo's `.kit.toml`, never here. Required when `notion-taskboard` is in `apps`. |
| - | sync.notion_taskboard_status_map | `""` | [impl] | sync | `board-state=OptionName` comma map to the team board's OWN Status options, e.g. `queued=Backlog,executing=In progress,parked=Waiting,shipped=Done`. `dropped` is skipped by default (never pushed). |
| - | sync.notion_taskboard_status_default | `""` | [impl] | sync | Status option for board states absent from the map (e.g. claimed/speccing/validated); without it, an unmapped state is a hard, guided error rather than a guess. |
| - | sync.notion_taskboard_priority_map | `""` | [impl] | sync | `tag=Option` map for Priority, e.g. `u-hi=P0,u-mid=P1,u-lo=P2` (derived from a row's `#u-*` tag). |
| - | sync.notion_taskboard_weight_map | `""` | [impl] | sync | `tag=Value` map for Weight, e.g. `f-hi=2,f-mid=5,f-lo=13` (from a row's `#f-*` tag). Optional. |
| - | sync.notion_taskboard_owner | `""` | [impl] | sync | Owner value set on create (a people-prop user id by default). Tenant id: consumer repo only. |
| - | sync.notion_taskboard_props | `""` | [impl] | sync | JSON overriding the target prop NAMES `{title,status,priority,weight,owner,notes}` (defaults Task/Status/Priority/Weight/Owner/Notes). |
| - | sync.notion_taskboard_types | `""` | [impl] | sync | JSON overriding the target prop TYPES `{status,priority,weight,owner}` (defaults status/select/number/people). |
| - | sync.notion_taskboard_only_tags | `""` | [impl] | sync | Down-filter: a row must carry one of these tags to be pushed to the Task Board. |
| - | sync.notion_taskboard_skip_tags | `""` | [impl] | sync | Down-filter: a row carrying any of these tags is never pushed to the Task Board. |

### session (incl. session-intel / skill-curator, prefix `SKILL_CURATOR_*`)

Expand Down
43 changes: 43 additions & 0 deletions lib/sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,49 @@ Live wiring (2026-07-16): Notion DB `612f123c-7476-4a78-90e2-e1465c0a0df6`
workspace `ntn` is logged into; move it if a personal workspace gets
connected). Hermes: `mini-tieubao`, `~/hermes-personal/home` (restic-covered).

## One-way push to a foreign team board (`notion-taskboard`, SPEC-003)

A fifth app, `notion-taskboard`, is NOT part of the two-way mesh: it is a
one-way, **insert-only** push of a repo's board rows to a foreign, team-OWNED
Notion board (implements ops-toolkit ID-138, design locked 2026-06-16). The
board is the source of truth; the sink is never read for merge and the board
file is never written. Fields are set ONLY on page-create, so a team member's
later edits on the card are never overwritten. The local sync-state map is the
identity index (a `bid` already pushed is never re-pushed), because the team
board's own ID column is a read-only auto-increment that cannot hold `DF-NN`.

Status / Priority / Weight are mapped to the team board's OWN option names via
config, so the sink never mutates the team schema: it reads the schema (never
PATCHes it) and validates every mapped option name exists BEFORE any create, so
a typo'd map value is a hard error instead of a silently auto-created select
option. `dropped` rows are skipped; a state absent from the map uses
`status_default` or, if unset, is a hard guided error. Validation runs at
preflight, so `--dry-run` surfaces config errors with zero writes, and state is
checkpointed after each create so a mid-batch failure never re-pushes a page.

```toml
[sync]
apps = "notion-taskboard"
notion_taskboard_db = "<database_id>" # tenant id: consumer repo only
notion_taskboard_status_map = "queued=Backlog,executing=In progress,parked=Waiting,shipped=Done"
notion_taskboard_status_default = "Backlog" # claimed/speccing/validated land here
notion_taskboard_priority_map = "u-hi=P0,u-mid=P1,u-lo=P2"
notion_taskboard_weight_map = "f-hi=2,f-mid=5,f-lo=13" # optional
notion_taskboard_owner = "<notion_person_id>" # optional (people prop)
```

Manual full-reconcile (the v1 trigger; a `board set` hook and Discord-on-shipped
are deferred, the latter needs update-detection insert-only v1 does not do):

```
_meta/board sync --apps notion-taskboard --dry-run # preview, no writes
_meta/board sync --apps notion-taskboard # push new rows
```

Prop NAMES/TYPES are overridable via `notion_taskboard_props` /
`notion_taskboard_types` (JSON); defaults are Task/Status/Priority/Weight/
Owner/Notes and status/select/number/people.

## Tests

```
Expand Down
Loading
Loading