feat: NIP-101 forms + AMB-serializer convergence (conformant Konfi ext namespace) #50

Merged
laoc merged 75 commits from feature/nostr-metadata-forms into dev 2026-07-29 10:39:39 +00:00
Owner

Lands the NIP-101 forms work and the AMB-serializer convergence (5 plans, 71 commits, rebased clean onto dev).

What's in here

  • NIP-101 forms alignment — kind-30168/1069 engine aligned to the Formstr wire format; bidirectional interop with @formstr/sdk verified.
  • Template-driven AMB resource form (kind-30142) + form-builder authoring UI (sections/steps, branching, field-output) + renderElement interop map.
  • AMB-serializer convergence — one canonical serializer via the shared amb-nostr-converter:
    • Template form writes via formValuesToAmbJson → ambToNostr and edit-reads via nostrToAmb → ambJsonToFormValues; retired in-app amb-emitters.js/form-to-amb.js.
    • Wizard EKW/Konfi now flow through amb.ext (fixes the live-preview omission).
    • Conformance fix (NIP-AMB amended ext: grammar): Konfi moved off the illegal ext:ekw:konfi:<slug>:id (5-segment, ambiguously parsed, corrupted relay data) to the conformant ext:org.edufeed.ekw.konfi:* namespace. Non-konfi EKW stays under ekw (already legal).

Verification

  • Full suite green (5004/5004), check 0 errors, lint clean; amb-basic E2E ran for real.
  • Runtime-verified live: published a Konfi resource through the real wizard → on-wire kind-30142 fully NIP-AMB conformant, ext:org.edufeed.ekw.konfi:* present, zero ext:ekw:konfi:*; deleted afterward (kind-5 confirmed on relays, 30142 gone).

Follow-ups (not in this PR)

  • Konfi event migration (scripts/migrate-konfi-namespace.mjs, dry-run verified): 11 existing ext:ekw:konfi:* events across 3 author pubkeys. Publish needs each author's signing key and should run after this deploys (and after NIP-BOSS's relay nostr_amb.go fix + converter write/parse guards land).
  • Non-blocking Minors tracked in the SDD ledger (stale doc comments, wizard vs template :type Concept parity).

🤖 Generated with Claude Code

Lands the NIP-101 forms work and the AMB-serializer convergence (5 plans, 71 commits, rebased clean onto dev). ## What's in here - **NIP-101 forms alignment** — kind-30168/1069 engine aligned to the Formstr wire format; bidirectional interop with @formstr/sdk verified. - **Template-driven AMB resource form** (kind-30142) + **form-builder authoring UI** (sections/steps, branching, field-output) + renderElement interop map. - **AMB-serializer convergence** — one canonical serializer via the shared `amb-nostr-converter`: - Template form writes via `formValuesToAmbJson → ambToNostr` and edit-reads via `nostrToAmb → ambJsonToFormValues`; retired in-app `amb-emitters.js`/`form-to-amb.js`. - Wizard EKW/Konfi now flow through `amb.ext` (fixes the live-preview omission). - **Conformance fix (NIP-AMB amended `ext:` grammar):** Konfi moved off the illegal `ext:ekw:konfi:<slug>:id` (5-segment, ambiguously parsed, corrupted relay data) to the conformant `ext:org.edufeed.ekw.konfi:*` namespace. Non-konfi EKW stays under `ekw` (already legal). ## Verification - Full suite green (5004/5004), `check` 0 errors, `lint` clean; amb-basic E2E ran for real. - **Runtime-verified live:** published a Konfi resource through the real wizard → on-wire kind-30142 fully NIP-AMB conformant, `ext:org.edufeed.ekw.konfi:*` present, zero `ext:ekw:konfi:*`; deleted afterward (kind-5 confirmed on relays, 30142 gone). ## Follow-ups (not in this PR) - **Konfi event migration** (`scripts/migrate-konfi-namespace.mjs`, dry-run verified): 11 existing `ext:ekw:konfi:*` events across 3 author pubkeys. Publish needs each author's signing key and should run **after this deploys** (and after NIP-BOSS's relay `nostr_amb.go` fix + converter write/parse guards land). - Non-blocking Minors tracked in the SDD ledger (stale doc comments, wizard vs template `:type Concept` parity). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
laoc added 71 commits 2026-07-29 06:01:37 +00:00
Extract build/parse into src/lib/helpers/forms/format.js: settings tag
replaces discrete description/public/confirmation_message/auto_response
tags, field tags gain a positional options array + renderElement-tagged
settings object, and choice fields encode [id,label] (or triples with a
nextSection config) instead of bare label strings. forms.js delegates the
moved exports so no import sites change. Events without a settings tag
still parse via a legacy path so old-dialect and foreign/garbage kind-30168
events never throw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FieldsRenderer now renders option labels but emits optionIds; multi-select
joins with ";" instead of ",". FormBuilder/FormBuilderFieldRow selectOptions
is FormFieldOption[] ({id,label}); the manual options editor generates ids
via generateOptionId. FormResponses maps stored optionIds back to labels for
display, passing through unknown ids for legacy label-valued responses.
Add forms/crypto.js adapters (nip44EncryptWith/nip44DecryptWith/signerHasNip44)
that try both the nested signer.nip44.{encrypt,decrypt} and flat
signer.nip44Encrypt/-Decrypt surfaces, and route every forms/membership call
site through them instead of ad-hoc raw checks. buildResponseTags now emits
the 4-element NIP-101 response tag shape (['response', id, value, '{}']).

The respond page now reads isPublic from parseFormTemplate(formEvent) instead
of the raw ['public'] tag, which is wrong for new-format (settings-JSON)
templates. MembershipApplicationForm's submit path used to silently fall back
to plaintext for signers that only exposed the nested nip44.encrypt surface
(e.g. NIP-07 extensions) because it only checked the flat nip44Encrypt method
— now it always attempts encryption and surfaces failures via the existing
error handling instead of leaking PII in plaintext.

Also migrated MembershipCard, MembershipApprovalsPanel, and SendFormModal,
which had the same raw nip44 surface checks, so crypto.js is the only path
left in forms/membership code (verified via grep).
Review follow-ups:
- MembershipApplicationForm test: signer without any NIP-44 surface must
  surface a visible error and publish nothing (no plaintext response tags).
  Verified the test fails against the old plaintext-fallback code.
- respond page: read confirmationMessage from parseFormTemplate instead of
  the raw confirmation_message tag (dropped on new-format templates).
- forms list page: same class of bug — raw description read and public-tag
  check migrated to parseFormTemplate (name/fieldCount along with them).
Foreign/malformed 30168 events can repeat option, section, or field ids;
those values feed keyed {#each} blocks in FieldsRenderer/FormRenderer and
a duplicate key crashes the whole page (each_key_duplicate). Dedupe by id
(keep first occurrence) in parseFormTemplate (NIP-101 and legacy paths)
and add defense-in-depth deduping to orderedSections for hand-built
templates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FormBuilder only carried required/placeholder/min/max/options/multiple out
of a loaded field's options, and never passed sections into
buildFormTemplateTags. Re-saving a script-published sectioned/branching
template silently wiped section grouping and per-field displayIf. There is
no branching-authoring UI yet, so this is a preserve-and-re-emit
round-trip fix: displayIf now rides through FieldState.displayIf (both the
edit-load and fork mappings) and back into the field's options on publish;
the loaded template's sections are captured into templateSections and
passed to buildFormTemplateTags when non-empty.

parseFormTemplate already carried displayIf through unmodified (it spreads
unknown fieldSettingsJSON keys), so no format.js change was needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RelationFieldAdapter owns the selected-relation list as field value since
AMBResourceSearchInput is add-only; adapter renders no label/error itself
(FieldsRenderer already does), matching the Task-2 CreatorFieldAdapter
correction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outbound sync $effect fired onchange([]) on mount of an empty relation
field: FormRenderer seeds non-vocab fields with the string default '', and
`refs=[] !== value=''` is always true, so it mutated values[field] ''→[] with
no user action. Delete the effect; emit only from add()/remove() directly and
keep refs as a read-only $derived for rendering (parent value flows back).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds rTagEmitter (NIP-24 r-tags) to the AMB emitter registry for the
external-urls field type, and ExternalUrlFieldAdapter wrapping
ExternalUrlInput per the registry contract (no own label/error,
readonly static list, no mount-time onchange).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ExternalUrlInput rendered `{#if label || m.external_url_label()}` so label=""
did not suppress its heading — the adapter showed two labels. Change to plain
`{#if label}` (CreatorInput convention). Only other caller (ResourceFormWizard
step 5) already passes an explicit label. Add real-component guard test that
renders the adapter through FieldsRenderer with the unmocked ExternalUrlInput.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add templateNaddr to the resource-form-variants registry (env-driven via
RESOURCE_FORM_TEMPLATE_NADDR_<VARIANT>, mirroring the schemeNaddrs pattern)
so a variant can point at a published kind-30168 form template. Extract the
create-resource route's body into TemplateResourceForm.svelte, shared by
/forms/[naddr]/create-resource and the new templateNaddr branch of
/create/resource/[variant]. Content is now derived from the form field whose
output is amb:description instead of a removed content pseudo-tag.
Extends the existing amb-basic form definition with the composite field
types landed in Tasks 2-5 (creator, amb-relation x2, external-urls, date,
plus url/checkbox/text-array/vocab-select) so the template exercises the
full slice-1 AMB field set. Wires sections through buildFormTemplate so
future forms with grouped sections serialize correctly.
Spec is named amb-basic-form.test.js (not .spec.js per the task brief) —
playwright.config.js testMatch is '**/*.test.js', so a .spec.js file would
never be picked up by pnpm run test:e2e.
Same pre-existing class of failure c640a759 fixed elsewhere: the loaders
barrel (loaders/index.js) resolves getArticleRelays/getEducationalRelays/
getCalendarRelays eagerly at module init through the shared community-loader
chain, but this suite's relay-helper mock didn't provide them, so the whole
file failed to collect any tests. Found while running the full verification
battery for the AMB-basic form-template slice (Task 7) — unrelated to that
slice's own code.

Two further suites (MembershipApplicationForm, MembershipCard) hit the same
class of gap but cascade into missing exports on additional mocked modules
(nostr-infrastructure's `pool`, applesauce-loaders' createReactionsLoader);
left unfixed as out of scope for this slice — see task report.
THIS slice regressed collection of MembershipApplicationForm.test.js and
MembershipCard.test.js — not pre-existing (corrected diagnosis). Root cause:
src/lib/config/form-field-types.js now statically imports the new adapters
(CreatorFieldAdapter → CreatorInput → profile-subscription.js → loaders/
profile.js; RelationFieldAdapter → AMBResourceSearchInput → loaders/
amb-search.js), so every FieldsRenderer/FormRenderer render eagerly evaluates
createAddressLoader/createReactionsLoader and get*Relays at module init. The
membership suites render a form, so their mocks — which didn't cover this
deeper chain — failed to collect. No runtime break: the loaders only throw
under the test-mock gap. Same test-mock-completeness class as c640a759 and
e0455525; assertions unchanged (7/7 MembershipApplicationForm still pass).

Fix: complete both suites' mocks for applesauce-loaders/loaders
(createAddressLoader/createEventLoader/createReactionsLoader), relay-helper.js
(get{Article,Educational,Calendar,Kanban,ProfileLookup,EventLoaderLookup,
Fallback}Relays), and nostr-infrastructure's pool.

Follow-up (later slice): the component field-type registry eager-static-imports
heavy adapters, so every form render pulls in profile + AMB-search machinery.
A lazy-loaded registry is the root fix.
Untouched checkboxes stored '' by FormRenderer were still emitting
isAccessibleForFree:false, giving every amb-basic resource a false
value regardless of user action. Emit no tag for undefined/null/''.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildAMBResourceTags can emit a ['d', url] tag itself (a url field mapped
to amb:id via dtagEmitter, e.g. the published amb-basic template), but
handleSubmit computed dTag before filtering, then discarded the emitted d
by always replacing it with crypto.randomUUID() — the user-typed
identifier landed on no tag at all. Extract resolveResourceDTag: edit mode
keeps the resource's existing d-tag for addressable stability, create mode
honors the emitted d and only falls back to a UUID when the form didn't
produce one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The field-output section said amb:<property> always emits under the bare
<property> key, but four props are special-cased by the emitter registry
(id→d, license→license:id, isAccessibleForFree→boolean with skip-when-empty,
description→also mirrors into event content). A foreign client following
only the bare-property rule would diverge. Add a table listing the four
special mappings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire Task 1's extractSections/interleaveSections/isSectionMarker helpers into
FormBuilder.svelte: a section is a type:'section' entry in the fields list,
addSection() appends one, the field render loop renders a divider (title +
optional description, reusing the same drag/move/delete controls) for
section items, publish() builds items then splits via extractSections into
formFields + settings.sections, and load/fork seed fields via
interleaveSections. Removes the read-only templateSections passthrough.
Groups the 16 amb-basic fields into 5 logical wizard steps: Grunddaten,
Einordnung, Rechte, Inhalt & Urheber, Beziehungen. Adds test to verify
round-trip parsing of sections via NIP-101 format.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drives the full builder-authoring flow (sections, option->section
routing, displayIf show-if) end-to-end through /forms/new and the
/respond fill wizard against the real relay + build. Passed on the
first run, no test.fixme needed - see COVERAGE.md for why the
publish/fill-navigation round trip is reliably observable here (local
eventStore + client-side nav), unlike the amb-basic-form.test.js
precedent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a third Section C to the form-builder authoring E2E and routes "Red"
there explicitly while linear order would visit Section B next. With only
2 sections an explicit route to "the next section" was indistinguishable
from fallthrough; skipping B proves the route (not fallthrough) drove
navigation. Keeps the displayIf show/hide assertions, now checked on both
the fallthrough path (Blue reaches C via B, condition false) and the
routed path (Red reaches C directly, condition true). Updates
e2e/COVERAGE.md to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Konfi's ext:ekw:konfi:<slug>:id was a 5-segment key ambiguously parsed
by relay (left-anchored) vs app (right-anchored), corrupting live data
for relay consumers. Amended NIP-AMB (nips@f822603) makes the grammar
normative: ns/facet MUST be colon-free. Lift konfi into a separate
ekw.konfi namespace (one EKW_KONFI_NS constant). Slice A already
conformant (ext:<form-d-tag>:<field-id>). Read parser repoints in
lockstep; no back-compat shim (Bumble re-publishes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TemplateResourceForm now writes and edit-reads kind-30142 resources through
the shared amb-nostr-converter (formValuesToAmbJson -> ambToNostr for write,
nostrToAmb -> ambJsonToFormValues for edit-prefill) via the new pure
buildTemplateResourceSubmission helper, which preserves the d-tag
reconciliation contract (edit mode never clobbers the existing d-tag) and
content fallback behavior.

getFormReferenceFromResource and resolveResourceDTag move to the new
src/lib/helpers/educational/formReference.js (also home to the shared
SelectedConcept typedef). form-to-amb.js and forms/amb-emitters.js are
retired along with their tests; the surviving getFormReferenceFromResource/
resolveResourceDTag tests move to formReference.test.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Key holder confirmed reverse-DNS + authorized re-publishing existing
Konfi events after the new pipeline is verified. Non-konfi ekw ns stays.
Task 7 (gated, dry-run-first) migrates old ext:ekw:konfi:* events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildResourceData whitelists fields; today it forwards only the pre-built
konfiTags array, not raw konfi scheme-key fields. Removing the hand-append
without forwarding raw fields would silently drop all Konfi facets from
real publishes (preview path masks it). Task 5 amended to fix the data
flow + test the real resourceData publish path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- konfiTags.js: repoint KONFI_PREFIX from ext:ekw:konfi: (illegal 5-segment
  shape) to ext:org.edufeed.ekw.konfi: (EKW_KONFI_NS). Drop the now-dead
  emitKonfiVocabTags/emitKonfiScalarTags; keep parseKonfiTags. Its allowCustom
  branch now reads the bare ext:<ns>:<facet> tag (no :custom suffix) to match
  how ambToNostr actually serializes a facet's mixed Concept[]/string[] items.
- buildResourceData.js: forward formData.bildungsbereich and, via a new
  config-driven collectKonfiRawFields() (walks BILDUNGSBEREICHE.konfi.step4SubSteps),
  the raw Konfi scheme-key fields (<schemeKey>Ids/Labels/Custom, scalar
  tagSlug values). Without this, formDataToAmbExt's Konfi branch sees
  undefined and real Konfi publishes emit zero konfi facets (Task-4 review
  finding). Drop the konfiTags param/arg.
- educational-actions.svelte.js: remove the formDataToEkwTags/konfiTags
  hand-append loops in createResource/updateResource — EKW/Konfi facets now
  come solely from amb.ext via ambToNostr. Re-derive isKonfi from
  formData.bildungsbereich (was reading the now-removed konfiTags array) and
  emit the Bildungsbereich NIP-32 L/l tag directly via a new
  getBildungsbereichTag() helper, decoupled from the deleted Konfi tag
  builder.
- resource-form-variants.js: EXTENSION_NAMESPACE_LABELS registry key moved
  from 'ekw:konfi' to EKW_KONFI_NS so the resource view page keeps showing
  curated Konfi facet labels under the new namespace.
- Delete now-dead formDataToEkwTags.js/formDataToKonfiTags.js and their
  emit-only unit tests (verified zero remaining production callers).
- Add publish-path tests (buildResourceData → convertFormDataToAMB →
  ambToNostr) proving EKW+Konfi facets and the ext:<ns>:<facet>[:sub]
  conformance grammar survive the real flow, not just the preview; rewrite
  konfiRoundTrip.test.js on the same real path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ran targeted vitest suites (413 files/4742 tests), pnpm check (0 errors),
pnpm lint (clean), and the full pnpm test suite (459 files/5004 tests) --
all green, nothing traced back to the Slice A/B converter migration; the
only "errors" are the pre-existing GlobalFAB teardown-race unhandled
rejections. Re-ran e2e/amb-basic-form.test.js: test 1 passes against the
converter-backed TemplateResourceForm; test 2 was temporarily un-fixme'd
to re-check whether the sandbox relay-read-back timeout had cleared --
it reproduced the identical documented timeout under the same
contention signature, confirming the limitation is environmental, not a
regression, so it stays test.fixme. Document the re-verification in
e2e/COVERAGE.md.
Re-publishes existing ext:ekw:konfi:* kind-30142 events into the
conformant ext:org.edufeed.ekw.konfi:* namespace. Mechanical, value-
preserving transform (incl. :custom -> bare tag, matching the converged
emitter). Dry-run over live relays: 11 events across 3 authors, 0
warnings. PUBLISH mode needs each author's signing key
(MIGRATE_NSEC_<pubkey8>); run post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parseTagKey still implemented the right-anchored heuristic ("last segment =
facet, prior segments joined by ':' = ns") that the amended NIP-AMB replaced.
On conformant data it agrees with left-anchored parsing, so nothing broke --
but it *guessed* at surplus-segment keys, so edufeed-app kept happily reading
un-migrated ext:ekw:konfi:* events that the relay and every other conformant
consumer must drop. Landed on this branch rather than a second worktree
because the branch already owns these files.

- parseExtensionTags.js: replace parseTagKey with left-anchored fixed arity,
  ported from amb-nostr-converter@ff26856 src/converters/nostrToAmb.ts --
  split on ':', offset 1 for 'ext:' / 0 for legacy 'ekw:', ns = segments[off],
  facet = segments[off+1], sub = the joined remainder or null, then validate
  sub against the closed set {id, type, name, prefLabel:<lang>} and return
  null otherwise. Delete the doc comment describing the old heuristic; it
  documented the bug. Rewrite the stale module header, which still claimed
  "<ns> may itself contain colons" and named the 30168:<pub>:<d> coordinate
  as the form namespace.
- Give sub === 'name' a real branch. It is in the NIP's closed sub set, so it
  now passes validation and would otherwise be parsed, accepted, and silently
  dropped -- the same failure class NIP-BOSS found in the converter's
  reconstructExt. Currently unreachable: 0 ext:*:*:name tags across 8476
  kind-30142 events scanned 2026-07-29.
- extensionMetadata.js: isFormDriven compared ns against the full
  30168:<pub>:<d> coordinate, which is not a legal <ns> and no longer survives
  parsing. Compare against the form's bare d-tag and drop formCoordToNs.
- Tests: invert the form-coordinate case to assert it is ignored, add the
  conformant ext:<form-d-tag>:* replacement, and add negative cases for legacy
  konfi keys and unknown subs.
- Document a pre-existing limitation surfaced while verifying this: a facet's
  kind is fixed by whichever tag is seen first, so a facet mixing concepts and
  free-text scalars loses one half, order-dependent. ambToNostr emits exactly
  that shape for a mixed amb.ext facet (the Konfi "custom value alongside
  vocabulary picks" case). Corpus impact is one event today; the wizard edit
  path is unaffected because parseKonfiTags reads the bare tag itself. Fixing
  it widens the Facet union that extensionMetadata switches on, so it is left
  out of the grammar fix deliberately.

Intended behaviour change: edufeed-app stops reading un-migrated
ext:ekw:konfi:*. This matches the no-shim ruling -- the two segmentations are
indistinguishable, so conformant consumers must ignore them rather than guess.
It makes the Task 7 migration the thing that restores those facets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A selector keyed on the presence of ext:ekw:konfi:* looks right and is wrong.
On the current corpus 8 of 25 ext-bearing events carry only bare scalars
(ext:ekw:bibleReference, ext:ekw:methodOther) and zero konfi keys. Those
events are already NIP-conformant; their defect is that the pre-fix relay
dropped 3-segment scalars from the index. Conversion runs at *index* time
(nostrlib typesense30142/replace.go), so the only thing that re-indexes them
is a re-publish. Selecting by konfi-key presence skips them and they stay
invisible on the relay after the migration is declared done -- fixed in code,
still broken in the index, and nobody looking any more. Caught by NIP-BOSS
against a claim of mine that reasoned from an aggregate and missed the split.

- Select kind-30142 under #l=ekw and take every ext-bearing event; rewrite
  konfi keys where present, re-sign unchanged where not. #l=ekw is a verified
  superset: a paginated scan of 8476 kind-30142 events (2026-07-29) found 0
  ext-bearing events lacking that label. ext:* keys are multi-char and not
  REQ-filterable; l is, so the filter moves server-side.
- Report the split in the dry run (N to rewrite / N to re-sign) and label each
  event's action, so a no-op re-sign reads as intentional rather than a bug.
- Document the scope and the deploy-before-migrate ordering at the top of the
  file: running --publish before nostrlib fix/amb-ext-grammar is live
  re-indexes through the old converter and wastes the run.
- Fix lint. The file was committed unformatted, so prettier --check failed and
  short-circuited eslint, masking 6 no-empty errors underneath. Both halves
  fixed; pnpm lint is green.

Dry run against the live relays: 25 ext-bearing under #l=ekw, 11 to rewrite,
14 to re-sign, 0 warnings -- matching the independent census exactly (6
konfi-only, 5 konfi+scalar, 8 scalar-only, 6 already clean).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Task 7 Step 1 still specified the konfi-keyed selector that skips the 8
  scalar-only events. Replace with #l=ekw + every ext-bearing event, with the
  reasoning and the expected set breakdown as a dry-run assertion.
- Task 7 Step 2: always bump created_at and re-sign so the event id changes. A
  byte-identical re-publish has a byte-identical id, and id-level dedupe exists
  in client caches and broadcast paths even though this relay's ReplaceEvent
  upserts unconditionally.
- Add Task 8 recording the parseExtensionTags grammar fix, and note in "Out of
  scope" that it fixes the grammar in place rather than routing the read path
  through nostrToAmb -- read-side convergence remains out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A facet was locked to a single `kind` by whichever tag arrived first, and
every later tag of the other kind was skipped. A facet carrying both
Concepts and free-text scalars therefore lost one half outright.

This is not hypothetical: `formDataToAmbExt.buildKonfiFacets` emits exactly
that shape for an `allowCustom` vocab field — the picked Concepts as
`:id`/`:prefLabel:*`/`:type` runs, then the custom string as a bare
`ext:<ns>:<facet>` tag. Concepts are always emitted first, so the loss was
deterministic: pick a vocabulary term *and* type a custom value, and the
custom value disappeared from the resource view. Only `zeitstruktur` has
`allowCustom` today, which is why the corpus shows no instance yet.

The relay already gets this right. `nostr_amb.go` accumulates concept
instances and scalars independently and concatenates them into
`ext[ns][facet]`, so the value is on the relay and correctly indexed — it
was only the app that dropped it. This ports that shape rather than
inventing a second one, so the two readers cannot drift apart again.

  Facet: { kind: 'concept'|'scalar', items }
      -> { kind: 'concept'|'scalar'|'mixed', concepts, scalars }

`items` is renamed rather than widened on purpose: every consumer breaks
visibly instead of silently keeping the old half-a-facet behaviour.

- buildExtensionCards renders a mixed facet through the scalar stack,
  concept labels first, so nothing is dropped. MetadataCardGrid renders
  `scalars` in place of `value`, so the two cannot share a card; the
  stacked list shows everything without touching the component.
- booleanFacetValue now requires kind === 'scalar'. A lone 'true' scalar
  next to concepts is not a flag, and treating it as one binned the
  concepts with it.
- summarizeExtensionFacets surfaces both halves under kind 'mixed'.

Verified: the new tests fail (15) against the previous parser and pass
against this one. Full suite 459 files / 5029 tests green, check 0 errors,
lint green.

Still outstanding, not in this commit: amb-nostr-converter's
`reconstructExt` (nostrToAmb.ts:511-519) has the identical first-tag-wins
defect and drops the scalar with no warning, so AMB JSON export is still
lossy. Same fix, same reference implementation. Owned by NIP-BOSS on
branch fix/amb-ext-grammar.
Author
Owner

Mixed ext: facets — concepts and free-text scalars both survive now (3ee3a208)

Follow-up to the grammar fix in this PR. parseExtensionTags locked a facet to a single kind by whichever tag arrived first and skipped every later tag of the other kind, so a facet carrying both Concepts and free-text scalars lost one half.

Not hypothetical: formDataToAmbExt.buildKonfiFacets emits exactly that for an allowCustom vocab field — Concepts as :id/:prefLabel:*/:type runs, then the custom string as a bare ext:<ns>:<facet> tag. Concepts are always emitted first, so the loss was deterministic, not order-dependent in practice: pick a vocabulary term and type a custom value, and the custom value vanished from the resource view.

Reproduced against the pre-fix parser:

in : ext:org.edufeed.ekw.konfi:zeitstruktur:id           urn:doppelstunde
     ext:org.edufeed.ekw.konfi:zeitstruktur:prefLabel:de Doppelstunde
     ext:org.edufeed.ekw.konfi:zeitstruktur:type         Concept
     ext:org.edufeed.ekw.konfi:zeitstruktur              2 x 90 Min.

out: { kind: 'concept', items: [{ id: 'urn:doppelstunde', prefLabels: { de: 'Doppelstunde' } }] }
                                                          ^ "2 x 90 Min." gone

The relay already gets this right

nostrlib/eventstore/typesense30142/nostr_amb.go accumulates concept instances and scalars independently and concatenates them. Probed on the same input:

{"org.edufeed.ekw.konfi":{"zeitstruktur":[{"id":"urn:doppelstunde","prefLabel":{"de":"Doppelstunde"},"type":"Concept"},"2 x 90 Min."]}}

So the value is on the relay and correctly indexed in Typesense — only the app dropped it. This commit ports the relay's shape rather than inventing a second one, so the two readers cannot drift apart again (which is the failure this whole workstream exists to fix).

The change

Facet: { kind: 'concept'|'scalar', items }
    -> { kind: 'concept'|'scalar'|'mixed', concepts, scalars }

items is renamed rather than widened on purpose — every consumer breaks visibly instead of silently keeping the old half-a-facet behaviour.

  • buildExtensionCards renders a mixed facet through the scalar stack, concept labels first. MetadataCardGrid renders scalars in place of value, so the two can't share a card; the stacked list shows everything without touching the component.
  • booleanFacetValue now requires kind === 'scalar'. A lone 'true' scalar next to concepts is not a flag, and treating it as one binned the concepts with it.
  • summarizeExtensionFacets surfaces both halves under kind 'mixed'.

Verification

new tests vs. previous parser   15 failed   (proves they are meaningful)
new tests vs. this parser       57 passed
pnpm test                       459 files / 5029 tests   green
pnpm check                      0 errors (4 pre-existing warnings in test fixtures)
pnpm lint                       green

Corpus check (31 #l=ekw events, wss://amb-relay.edufeed.org): zero mixed facets today, and zero after simulating migrate-konfi-namespace.mjs end to end. zeitstruktur is the only allowCustom field, and the one live custom value (f8fc9ce8…, "2 x 90 Min.") has no vocabulary picks beside it. The bug was prospective — it would have bitten the next Konfi author who used both.

Also confirmed while checking: the migration already rewrites the legacy ext:ekw:konfi:<slug>:custom key to the bare scalar shape, so that value is carried across correctly rather than landing on an illegal key. Simulated over the corpus: 0 ext keys outside the grammar afterwards.

Not in this commit

amb-nostr-converter's reconstructExt (nostrToAmb.ts:511-519) has the identical first-tag-wins defect and drops the scalar with no warning — verified by probe. AMB JSON export is still lossy until it lands. Same fix, same reference implementation. Owned by NIP-BOSS on fix/amb-ext-grammar.

## Mixed `ext:` facets — concepts and free-text scalars both survive now (`3ee3a208`) Follow-up to the grammar fix in this PR. `parseExtensionTags` locked a facet to a single `kind` by whichever tag arrived first and skipped every later tag of the other kind, so a facet carrying **both** Concepts and free-text scalars lost one half. Not hypothetical: `formDataToAmbExt.buildKonfiFacets` emits exactly that for an `allowCustom` vocab field — Concepts as `:id`/`:prefLabel:*`/`:type` runs, then the custom string as a bare `ext:<ns>:<facet>` tag. Concepts are always emitted first, so the loss was **deterministic**, not order-dependent in practice: pick a vocabulary term *and* type a custom value, and the custom value vanished from the resource view. Reproduced against the pre-fix parser: ``` in : ext:org.edufeed.ekw.konfi:zeitstruktur:id urn:doppelstunde ext:org.edufeed.ekw.konfi:zeitstruktur:prefLabel:de Doppelstunde ext:org.edufeed.ekw.konfi:zeitstruktur:type Concept ext:org.edufeed.ekw.konfi:zeitstruktur 2 x 90 Min. out: { kind: 'concept', items: [{ id: 'urn:doppelstunde', prefLabels: { de: 'Doppelstunde' } }] } ^ "2 x 90 Min." gone ``` ### The relay already gets this right `nostrlib/eventstore/typesense30142/nostr_amb.go` accumulates concept instances and scalars independently and concatenates them. Probed on the same input: ```json {"org.edufeed.ekw.konfi":{"zeitstruktur":[{"id":"urn:doppelstunde","prefLabel":{"de":"Doppelstunde"},"type":"Concept"},"2 x 90 Min."]}} ``` So the value is on the relay and correctly indexed in Typesense — only the app dropped it. This commit ports the relay's shape rather than inventing a second one, so the two readers cannot drift apart again (which is the failure this whole workstream exists to fix). ### The change ``` Facet: { kind: 'concept'|'scalar', items } -> { kind: 'concept'|'scalar'|'mixed', concepts, scalars } ``` `items` is **renamed rather than widened on purpose** — every consumer breaks visibly instead of silently keeping the old half-a-facet behaviour. - `buildExtensionCards` renders a mixed facet through the scalar stack, concept labels first. `MetadataCardGrid` renders `scalars` *in place of* `value`, so the two can't share a card; the stacked list shows everything without touching the component. - `booleanFacetValue` now requires `kind === 'scalar'`. A lone `'true'` scalar next to concepts is not a flag, and treating it as one binned the concepts with it. - `summarizeExtensionFacets` surfaces both halves under kind `'mixed'`. ### Verification ``` new tests vs. previous parser 15 failed (proves they are meaningful) new tests vs. this parser 57 passed pnpm test 459 files / 5029 tests green pnpm check 0 errors (4 pre-existing warnings in test fixtures) pnpm lint green ``` Corpus check (31 `#l=ekw` events, `wss://amb-relay.edufeed.org`): **zero mixed facets today**, and zero after simulating `migrate-konfi-namespace.mjs` end to end. `zeitstruktur` is the only `allowCustom` field, and the one live custom value (`f8fc9ce8…`, `"2 x 90 Min."`) has no vocabulary picks beside it. The bug was prospective — it would have bitten the next Konfi author who used both. Also confirmed while checking: the migration already rewrites the legacy `ext:ekw:konfi:<slug>:custom` key to the bare scalar shape, so that value is carried across correctly rather than landing on an illegal key. Simulated over the corpus: 0 ext keys outside the grammar afterwards. ### Not in this commit `amb-nostr-converter`'s `reconstructExt` (`nostrToAmb.ts:511-519`) has the **identical** first-tag-wins defect and drops the scalar with **no warning** — verified by probe. AMB JSON export is still lossy until it lands. Same fix, same reference implementation. Owned by NIP-BOSS on `fix/amb-ext-grammar`.
laoc merged commit 242a22fb84 into dev 2026-07-29 10:39:39 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
edufeed/edufeed-app!50
No description provided.