Skip to content

Plugin custom fields: list fields with an observed-remove merge (nxf 6j6v.4g6r) - #63

Merged
cabcookie merged 4 commits into
mainfrom
feat/4g6r-list-fields
Oct 15, 2026
Merged

cabcookie merged 4 commits into
mainfrom
feat/4g6r-list-fields

Conversation

@cabcookie

@cabcookie cabcookie commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Stacked on #61 (vbp6, datetime). Merge #61 first; until then this diff includes its commit.

What

A plugin can declare a list field: [fields.email] type = "text" list = true. Each entry is a value of the field's type (text, date, datetime, enum, validated as a single value is) with an optional free-text label (work, private, …).

surface add remove read
CLI nxf entry add <id> <field> <value> [--label L] nxf entry remove <id> <field> <value> nxf entry list <id> <field>
MCP flow_entry_add flow_entry_remove flow_entry_list
Engine field_entry_add field_entry_remove field_entries

update --set <field>= (empty) clears a list; a non-empty --set (or create --set) on a list field is refused with a pointer to the entry verbs. Records carry entries under custom as [{"value": …, "label": …}] (label only when set, empty list omitted). nxf schema --json / flow_schema add "list": true beside kind.

Merge semantics (OR-set, add wins)

  • Identity = the value within (item, field). Concurrent adds of one value → one entry.
  • Label = label of the add with the highest (lamport, site, op id); relabel = re-add with the new label (no --label → unlabelled).
  • Remove/clear tombstone the adds the writer observed, whatever their label; an unobserved concurrent add (re-add, relabel, new value) survives.
  • Order = earliest present add of each value; a relabel keeps the place, remove + re-add goes last.

Core / hosted impact

  • Ops: target_kind = field, op_type = add (value + U+001F + label) / remove (observed tags, ≤1024 per op). Older binaries store-don't-fold them.
  • One new view field_entries(item_id, field, tag, value, label, lamport, site, removed), PK (item_id, field, tag); a remove only sets removed = 1 on the add's row, so any order converges on both appliers. Projection shared by replica and server (model::present_entries).
  • Task reducer fold revision 2 → 3: replicas and hosted streams refold once. fold-ddb index revision unchanged (INDEX_REVISION stays 3), no GSI / table-contract change; the hosted records readers now also refuse a stream whose watermark names another task revision (RecordsError::NotCurrent { domain, found }, domain new) until it is refolded — no extra refold beyond the one the task bump already causes; hosted records read one more Query per item (field_entries#<item>#): active board 5n + 1 + p.
  • Bounds (fold semantics): entry value+label ≤ 16 KiB, ids ≤ 128 bytes.

Refused at load

list = true with longtext, with merge, or with required = true; a ranking key of = { field = <list> }.

Facade: breaking

plugin::FieldDecl and read::SchemaField gain list and become #[non_exhaustive]; ActiveTicket/ListItem gain custom_lists (already non-exhaustive); new Engine::field_entry_add/remove/field_entries, read::field_entries, read::field_entries_to_value, write::entry_add/remove; FieldEntry re-exported. After review mitigation: Engine::field_entry_add/write::entry_add return the stored FieldEntry, Engine::field_entry_remove/write::entry_remove return the matched value (String), new write::entry_add_receipt/entry_remove_receipt; a list field name over 64 characters is refused at plugin load. app-foundations follows with 41j0.bm81.

Tests

  • core: tests/field_entries.rs (two replicas: concurrent same-value add, remove vs re-add/relabel → add wins, concurrent removes, concurrent relabels, clear vs add, remove-before-add, malformed ops, 32 shuffled orders); full core suite green.
  • facade: plugin load validation, ranking refusal, Engine seam, actor contract; full facade suite green.
  • fold-ddb: shared generator writes entries → fold/records/list differentials compare served vs local (rows, records, JSON); cost tests updated; all green, including dynamodb_local against DynamoDB Local 3.3.1 (7/7).
  • plugin-probe: file-fixture gains mirrors list; facade surfaces, sync round trip, and the real nxf command tree in-process; green.
  • MCP handler test in-process; tests/mcp.rs surface/annotation tests; CLI verb_seam, schema, golden (entries.trycmd); cargo xtask docs check/coverage, changelog check ok.

Spec: docs/specs/plugin-custom-fields.md §5.2 (+ TB-CF-4, E1 §3.2, E4 cost). Guide EN/DE "List fields".

🤖 Generated with Claude Code

https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME

cabcookie and others added 3 commits October 12, 2026 11:20
…xf 6j6v.vbp6)

A plugin can declare `[fields.<name>] type = "datetime"`. A value must be an
RFC 3339 timestamp with a time zone; a plain date or a local time is refused.
It is stored as the same instant in UTC at whole seconds
(`2026-10-12T10:30:00+02:00` -> `2026-10-12T08:30:00Z`), so text order is
chronological order for every downstream comparison (derived ranking, hosted
fold, SQL) with nothing type-aware there. `FieldType` gains `Datetime` and
becomes #[non_exhaustive] (facade: breaking).

Tests: validator unit tests, plugin declaration, facade write path, Engine
seam, derived ranking `of = { field }`, MCP handlers, schema --json via the
file-fixture consumer, and the fold-ddb served-board round trip. Spec §5.1
and the plugins guide (EN/DE) document the type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME
…(nxf 6j6v.4g6r)

A plugin can declare `[fields.<name>] list = true`: the field holds entries, each
a value of its type (text, date, datetime, enum) with an optional free-text label
(work, private, ...). Entries are written with `nxf entry add/remove`, read with
`nxf entry list`, over MCP with flow_entry_add/remove/list and on the facade with
Engine::field_entry_add/field_entry_remove/field_entries. `--set <field>=` clears
a list; a non-empty `--set` is refused. Records carry the entries under `custom`
as [{value, label?}]; `nxf schema --json` marks the field "list": true.

Merge: the value is the entry's identity; several present adds of one value are
one entry, labelled by the latest add and placed by the earliest; a remove (or a
clear) tombstones the adds its writer saw, so a concurrent add survives (add
wins). Ops share the `field` kind (op_type add/remove) and fold into one table,
field_entries(item_id, field, tag, ...), keyed with the item leftmost: one Query
per item on a hosted board, no index change. Task fold revision 3.

A list field cannot be required, a longtext, take a merge, or be read by a
ranking key (refused at load). facade: breaking (FieldDecl/SchemaField gain
`list` and become #[non_exhaustive]).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME
… guard, stored-form receipts (nxf 6j6v.4g6r)

- fold-ddb: the records readers refuse a stream whose watermark names another
  task-reducer revision than this build's (NotCurrent now names the domain);
  INDEX_REVISION stays 3. The DynamoDB-local cost test expects 4n Queries
  (five requests per ticket); cost docs in lib.rs and the E4 spec follow.
- core: one entry separator, model::ENTRY_SEP. An entry add whose op id holds
  it is not folded, live_entry_tags skips such tags, and an entry op's field
  must meet the chunk-field grammar so a server's sort key stays under 1 KiB.
  A plugin's list field name is at most 64 characters.
- facade: write::entry_add returns the stored FieldEntry, entry_remove the
  value as matched; entry_add_receipt / entry_remove_receipt are the one
  spelling the CLI and MCP share. entry_remove ignores the `on` scope and also
  matches a datetime's text as given. validate::datetime trims its input.
- spec 5.2: tombstone growth and phantom tombstones (follow-up 6j6v.2sbr),
  the op-id and field-name rules, receipts, removal scope.
- golden: every case must have a sandbox, asserted before trycmd runs.
- tests: task revision 2 -> 3 refold (local and hosted), hostile op ids,
  typed entries, 16 KiB limit, on scope, Engine and MCP refusals, the real
  `nxf entry list` output through a probe-nxf subprocess, leap second and
  space separator, a relabel that really races a remove, pinned labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q5uMUkZJQW4fckJoA3FME
@cabcookie

Copy link
Copy Markdown
Member Author

Mitigation of the review (report 2026-10-12T12-02-18Z), commit 197a136

Finding (source · severity) Disposition Notes
Stale DynamoDB-local cost test (CQ #1 / TQ #1 · High) Fixed dynamodb_local.rs expects 4 * n Queries, renamed to …five_requests_per_ticket…; stale "three Queries" in lib.rs and E4 §5 cost text corrected. Ran against DynamoDB Local 3.3.1: 7/7 green
Hosted readers ignore the task revision (CQ #2 / Int #3 · Medium/Low) Fixed check_current compares the index AND task-reducer revisions; NotCurrent { domain, found } names which. INDEX_REVISION unchanged (3), so no extra refold. Test: a_stream_folded_by_task_revision_2_is_refused_then_refolded_and_reads_its_list_entries (active board + list)
Receipts echo raw input (CQ #3 · Medium) Fixed write::entry_add returns the stored FieldEntry, entry_remove the matched value; write::entry_add_receipt/entry_remove_receipt are the one spelling used by CLI and MCP (a datetime shows UTC)
Separator spelled four ways (CQ #4 · Low) Fixed One constant, model::ENTRY_SEP, in model, reducer, store, write
on scope asymmetry (CQ #5 · Low) Fixed (decided) Add keeps on; remove ignores it, so every entry on show can be removed (same reasoning as removing a dropped enum member). Documented in spec §5.2, the guide, and the doc comments; tested
Datetime trimming (CQ #6 · Low) Fixed validate::datetime trims, so --set and entries match; tests cover leap second (:60 → :59) and a space separator with zone (accepted, stored with T)
1 MB claim false (CQ #7 · Low) Fixed records.rs doc rewritten: the range grows with history, pagination is followed, compaction filed
Task revision 2→3 untested (TQ #2 · Medium) Fixed Local: a_store_folded_at_task_revision_2_folds_its_entry_ops_on_open; hosted: see above
Golden sandbox guard (TQ #3 · Medium) Fixed golden.rs fails if any .trycmd lacks <name>.in/ + <name>.out/, checked before trycmd runs; guide/agent-manifest got sandboxes. Confirmed it fails when a sandbox is removed
Typed entry validation untested (TQ #4 · Medium) Fixed facade tests/write.rs: enum non-member, bad dates, datetime stored/reported in UTC, remove across offsets, 16 KiB boundary (at limit / +1), on scope, dropped member removable
Successful nxf entry list output (TQ #5 · Medium) Fixed New plugin-probe bin probe-nxf (real command tree + fixture) runs as a subprocess with $HOME pinned in a temp workspace; exact human and --json stdout plus receipts asserted
Identical test bodies (TQ #6 · Low) Fixed The relabel test now races a relabel against a remove of an entry ahead of another, and checks the entry loses its place
Shuffle checks only values (TQ #7 · Low) Fixed Expected labels pinned and worked out by hand in the comment
Engine-seam failure paths (TQ #8 · Low) Fixed engine_field_entry_writes_return_the_stored_form_and_refuse_at_the_seam
MCP refusals (TQ #9 · Low) Fixed flow_entry_tools_refuse_with_the_error_kind_the_envelope_carries (kinds + receipts)
Datetime edge cases (TQ #10 · Low) Fixed See CQ #6
U+001F in op id poisons the field (Int #1 · Medium) Fixed parse_entry_add refuses such op ids; live_entry_tags skips them; two hostile-peer tests
Tombstone growth / phantom tombstones (Int #2 · Medium) Documented + filed Spec §5.2 "Tombstones grow and are not collected"; compaction / separate-prefix follow-up 6j6v.2sbr (P3)
Sort key may exceed 1 KiB (Int #4 · Low) Fixed An entry op's field must match the chunk-field grammar (≤64 plain ASCII) in the reducer, so both appliers agree; list field names over 64 characters are refused at plugin load
Peer-written non-normalized datetime (Int #5 · Low) Fixed entry_remove also removes the text as given; tested

Facade changes beyond the PR's original note: Engine::field_entry_add → Result<FieldEntry>, Engine::field_entry_remove → Result<String> (same for write::entry_add/remove), new write::entry_add_receipt / entry_remove_receipt. In fold-ddb (unpublished), RecordsError::NotCurrent gains domain. The changelog fragments are updated.

Gates run locally: core field_entries (17), crdt_properties, lib · facade lib (155), write (70), engine (29), read, actor_contract · fold-ddb full crate (all differentials + robustness) · dynamodb_local against DynamoDB Local (7/7) · cli golden, verb_seam, schema · plugin-probe list_field_consumer (6), ky26_file_consumer · nxs lib custom_field_tests (6), --test mcp flow (41) · cargo fmt --all · cargo xtask docs check · cargo xtask changelog check origin/main. Clippy is left to CI.

@cabcookie
cabcookie merged commit 7098967 into main Oct 15, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant