Skip to content

feat(identifier): a case number from a named sequence and a format, declared on the property - #3785

Merged
rubenvdlinde merged 13 commits into
developmentfrom
feat/generated-identifier
Sep 16, 2026
Merged

rubenvdlinde merged 13 commits into
developmentfrom
feat/generated-identifier

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes ledger row 2.1 for the sequence and the format. Implements openspec/changes/generated-identifier/, tasks 1.1 through 3.2.

What this adds

A string property declares where its number comes from, and every object gets one on create.

{
  "identifier": {
    "type": "string",
    "title": "Case number",
    "x-openregister-generated": {
      "sequence": "case",
      "format": "Z-{year}-{seq:5}",
      "resetOn": "year"
    }
  }
}

The first case of 2026 reads Z-2026-00001. The value cannot be edited afterwards, and an import that brings its own number keeps it and pushes the counter past it.

The decision worth reviewing first: no second sequences table

The design sketched a new openregister_sequences (name, period, value). That table already exists, under a different shape, and SequenceService::reserveNext() already hands out numbers under a row lock inside a transaction for the declarative sequence calculation operator.

So this change draws from that allocator instead of adding a second one beside it. No migration, and one uniqueness argument to get right rather than two. A named counter is stored at register 0 and schema 0, a pair no register and no schema can carry, which is what makes "two schemas naming the same sequence share one counter" true. Scoped per register and schema, as the calculation operator is, that requirement would silently be two counters that both start at one.

What the annotation adds over that operator is the part a Twig expression cannot do: the declaration lives on the property rather than in a calculation AST, the counter is named and therefore shareable, the value is frozen after it is issued, and an import advances the counter.

The other three

Render and parse live in one class. A format that renders one way and reads back another is an import that advances the wrong counter, or no counter, and that is invisible until two records carry the same number. The parse pattern is built from the same format string that renders, so a placeholder added to one is added to the other. Every rendering case in the tests is read straight back.

raiseTo() carries its own next_value < ? clause. A plain SET would let an import of old numbers push the counter backwards, and the next create would re-issue a number already printed on a record. The clause is what makes the advance safe to run beside live creates.

Schema save refuses three declarations, all of which would produce values that look right. An unknown placeholder ships as its own literal text. A format with no {seq} gives every object the same identifier. A resetOn: year whose format never renders {year} works for a year and then re-issues every number from the year before. The refusal extends PropertyVocabularyException, so every schema-save path already answers it as 422 naming the property, and no controller had to learn this annotation exists.

The mask contract for the dossiq consumer

Everything below is config on the schema. There is no PHP in the leaf app and no call to make.

Key Meaning
sequence The counter's name. Two schemas naming the same one share it
format {seq:n} zero-pads to n digits and grows past it rather than wrapping; {year} and {month} come from the creation time; everything else is literal
resetOn never (default) or year
  • On create, an empty declared property is filled. POST {"title":"A new case"} answers {"title":"A new case","identifier":"Z-2026-00001", …}.
  • An update that changes the property is refused with 422. An update that omits it is an ordinary edit and is allowed.
  • A create or import that supplies a value keeps it and advances the counter, so importing Z-2026-00120 makes the next generated case Z-2026-00121. A value the format does not recognise is left alone and moves no counter.
  • Gaps are allowed, reuse is not. A number is taken before the object is written, so a failed create leaves a hole. Pending proposal 4.26 wants gapless numbering; that is recorded as a disagreement and is not resolved here.

ComplaintService::generateComplaintNumber in dossiq is the hack this replaces. dossiq is not touched by this PR.

What is deliberately left out

The discovery-cluster-41 extension (REQ-GID-004, 005 and 006: random sequence kinds, foreign identifiers naming their issuer, a second human identifier, reserved values, and a scheme change as a background job) is not in this PR. Those requirements and their eight tasks are untouched and stay unticked, so the change is not marked complete.

Verification

  • diff-check.sh --base development: result in the comment below.
  • composer check:strict and npm run lint: results in the comment below.
  • PHPUnit: 27 new tests over two files, all green. Two mutations were run to prove they can fail. Removing the update refusal reddens testAnUpdateThatChangesTheIdentifierIsRefused on its own assertion; making parse() ignore the year in the value reddens the round-trip test and testTheParsedPeriodComesFromTheValue, each on its own assertion line rather than on setup.
  • Playwright: tests/e2e/api-direct/generated-identifier.spec.ts, five tests, hermetic. Not run locally (no live instance in this lane); it runs in CI.

Two of the unit tests build a real ObjectEntity rather than a double, because setObject() is answered by the Entity base class's __call and a double cannot be told what to do with it. A double that could would have been asserting itself: what the listener writes back through that setter is the entire behaviour under test.

🤖 Generated with Claude Code

…n-in

Section 8 of permission-provenance-and-deny: the three shapes of grant the
register row asked for, and the recount when the rule behind one moves.

AN END ON A GRANT (8.2). An entry carrying `until` stops answering the moment it
passes, read at resolution rather than swept. Nothing has to run to take the
right away, which matters because the job that takes a right away is the one
nobody notices has stopped. A workflow step binds a grant to its deadline by
writing that deadline in: no second clock to drift, and a step that moves its
deadline rewrites the grant the same way it wrote it.

AN AREA ON A GRANT (8.4). An entry carrying `scopedTo` answers only in the
registers and schemas it names, so `manage` can be delegated without handing the
instance over. The key is `scopedTo` and not `scope`, because a block already
carries a `scope` naming an object's visibility, and a second meaning for one
word in one block is how a rule gets read by the wrong reader.

ACCESS DERIVED AT SIGN-IN (8.1). Rules map what an identity provider asserts to
groups, a role and an area. OpenRegister does not speak to a provider and should
not start, so it asks the app that holds that session through
IdentityClaimsCollectingEvent and reads whatever comes back. A sign-in that
asserts nothing leaves the account with no derived access rather than with
yesterday's, because carrying the old set forward keeps somebody in a department
they left. A derived group is never a reserved one: `admin`, `public`,
`authenticated` and `mcp` are filtered out, so a rule can never hand a provider
the power to make somebody an administrator here. The fold happens after the
administrator check for the same reason, and a test asserts it.

Both constraints are read in resolveAuthorization, beside the mcp strip, because
that is the one step every path takes: the object read, the relation check and
both list emitters. A grant that expired on one surface and not another would be
the worst possible version of this.
…ants moved

Task 8.3. An access change nobody is told about is the one that surprises an
auditor, and narrowing a rule that reached two hundred people is a decision
somebody should see the size of before they walk away from the screen.

POST /api/permissions/derived-grants/reapply re-derives every account that has
claims stored and answers in numbers, naming the accounts whose access moved. The
claims are kept at sign-in precisely so this can run without waiting a fortnight
for everybody to sign in again.

Administrator only, and by the framework rather than by a check in the method:
the route carries no NoAdminRequired, so the middleware refuses everybody else
before the method runs. A body check beside that attribute is the exact mismatch
the semantic-auth gate exists to catch.

An instance with no rules says so rather than reporting that nothing moved. The
second reads as a rule that had no effect, which is the answer somebody
debugging a rule would most like to be given by mistake.
… get shorter

phpmd was right on all seven counts, and every one of them was the same shape:
something had grown past the size where it can be checked by eye.

The re-run moved out of PermissionsController, which was at the complexity and
coupling thresholds with it. Derived grants are a different noun from the
permission catalogue anyway: one publishes what can be granted, the other
re-runs what was derived.

derive(), apply() and names() each gave up a branch to a named helper, so the
reason a rule is skipped, the way one block key is filtered, and the spellings an
area answers to are now three things with names instead of three nested
conditions. The callback the account walk passes returns null rather than void,
because the return value is what stops the walk and stopping it would silently
leave the rest of the accounts unre-derived.
Gate 16 was right: claimsFor() and forget() are public and changed in this PR,
and neither said which requirement it serves.
…the counter that already exists

The sequence allocator this needs was already here. `SequenceService::reserveNext`
backs the declarative `sequence` calculation operator and hands out numbers under
a row lock inside a transaction, so this change draws from it rather than adding
a second `openregister_sequences` table beside it. No migration, and one
uniqueness argument to get right instead of two.

What the annotation adds over that operator is the part a Twig expression
cannot do: the declaration lives ON the property rather than in a calculation
AST, the counter is NAMED so two schemas can share it, the value is frozen
after it is issued, and an import that supplies its own number pushes the
counter past it.

A named counter is stored at register 0 and schema 0, a pair no register and no
schema can carry. That sentinel is what lets "cases and complaints number from
one counter" be true; scoped to a real register and schema, as the calculation
operator is, it would silently be two counters that both start at one.

The declaration parses, validates, renders and reads back in one class, because
a format that renders one way and parses another is an import that advances the
wrong counter, and that is invisible until two records carry the same number.

Schema save refuses three declarations that would all produce values that look
right: an unknown placeholder, a format with no {seq}, and a yearly reset whose
format never renders the year. The last works for a year and then re-issues
every number from the year before.
… is wired to both events

Four edits to existing files.

SequenceMapper gains raiseTo(), which is an UPDATE carrying its own
`next_value < ?` clause. That clause is the whole point: a plain SET would let
an import that supplied an old number push the counter BACKWARDS, and the next
create would re-issue a number already printed on a record. seedScope() now
takes the first value, defaulting to what it stored before, so nothing that
called it changes behaviour.

The declaration is validated where every other property key is, in
validateProperty(), and its refusal extends PropertyVocabularyException. Every
schema-save path in SchemasController already catches that one and answers 422
naming the property, so no controller had to learn this annotation exists.

The listener is registered on ObjectCreatingEvent beside
LifecycleInitialStateListener, and on ObjectUpdatingEvent for the freeze. Both
registrations are in the same change on purpose: a frozen identifier that is not
frozen fails in the quietest way available, with the number in the letter
drifting from the number in the record and nothing anywhere erroring.
…e allowed

The page leads with the shape an app author types, because that is the whole
interface: three keys on a property. It then says plainly what the schema save
refuses and why each refusal exists, since all three describe declarations that
produce values which look right.

Gaps get their own section. A number is taken before the object is written, so
a failed create leaves a hole, and that is a decision rather than an oversight:
reserving without burning means holding a lock across the whole write. Pending
proposal 4.26 wants gapless and is named as an open disagreement, not resolved.

The two shipped scenarios in the delta now point at the Playwright spec instead
of carrying a proposal-only exclusion. The discovery-cluster-41 requirements
are untouched, and their tasks stay unticked: random sequence kinds, foreign
identifiers, a second human identifier, reserved values and the renumbering job
are not in this change.
…t an inline if

38 NEW phpcs findings from diff-check, all this change's own. Thirty-six are
the repo's named-parameter rule in the two test files and on the exception's
parent constructor. The thirty-seventh is an inline if choosing the pad when a
{seq} carries none; it becomes three lines that say the same thing, and the
comment above it already explained the intent.

0 errors now. The eleven warnings that remain are the @spec-points-at-a-change
-dir one, which every file shipped by a change that is not archived yet carries.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ dbfb5f8

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
check-l10n-js
composer ✅ 174/174
npm ✅ 653/653
app:check-code ⏭️
info.xml
REUSE
lockfile sync
PHPUnit
Newman
Playwright ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it on any branch from the Actions tab, or locally with npx playwright test.
Hydra gates

Quality workflow — 2026-09-15 19:07 UTC

Download the full PDF report from the workflow artifacts.

Gate 16 wants a @SPEC on every changed public method, and format() and
sequence() had none. The sequence tag points at the shared-counter requirement
rather than the general one, because that is the requirement the NAME exists
for: two schemas naming the same counter is how they come to share it.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 6968df2

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
check-l10n-js
composer ✅ 174/174
npm ✅ 653/653
app:check-code ⏭️
info.xml
REUSE
lockfile sync
PHPUnit
Newman
Playwright ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it on any branch from the Actions tab, or locally with npx playwright test.
Hydra gates

Quality workflow — 2026-09-15 19:44 UTC

Download the full PDF report from the workflow artifacts.

…indings go

composer phpmd runs on the whole tree rather than inside diff-check, so these
seven surfaced only in check:strict, and all seven were this change's own.

parse() was at cyclomatic 11 and NPath 360 because it built the read-back
expression and then read a value with it. The build moves to readBackPattern(),
and the per-placeholder capture group to groupFor(). Both carry the reason the
lower bound is the pad with no upper bound: a value that grew past its padding
has to stay parseable, or importing it advances no counter at all.

Extracting it broke parse(), and the tests said so immediately: `{seq:5}`
captures as `seq:5`, not `seq`, so recording the raw capture left every padded
sequence unrecognised and parse() answered null for a value the same format had
just rendered. The kind is normalised before it is recorded, and that is what
the three red tests were pointing at.

The freeze loop wanted property names and not declarations, so it iterates
array_keys(). The two StaticAccess findings get a reason rather than a fix:
fromProperty() is a named constructor, and injecting a factory to build a value
object from an array the caller already holds would add a collaborator that
answers one question and keeps no state.

$at becomes $moment in the two public methods and at every call site, the same
rename the favourites change made for the same rule.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Verification result

COMPOSER_PROCESS_TIMEOUT=0 composer check:strict, run once. Three stages exit non-zero. All three are inherited, and each is classified by evidence rather than by assertion.

Stage Exit Reading
lint 0 clean
check:migration-version 0 no migration in this change
phpcs 0 clean
phpmd 2 57 findings past phpmd.baseline.xml, 0 in this change's files, counted directly on a re-run after the fixes below
psalm 0 clean
phpstan 1 1000+ whole-tree errors across 250+ files, truncated by phpstan's own limit. 0 in this change's files: phpstan on them exits 0 with [OK] No errors, and diff-check's diff-scoped phpstan reported 0 NEW
test:all 1 21,535 tests, 53,781 assertions, 0 failures, 0 errors. The exit code is the known No code coverage driver available runner warning

npm run lint: 932 problems, 0 errors, all inherited jsdoc and vue warnings.

diff-check.sh --base development: gates, php -l, phpcs, phpstan, phpunit and eslint. It found two rounds of NEW findings, both fixed in this branch: 38 phpcs (named parameters, one inline if) and gate-16 spec-coverage on two accessors.

What the whole-tree phpmd caught that diff-check could not

Seven findings, all this change's own, because composer phpmd runs on the whole tree and is not one of diff-check's tools. Fixed rather than waved through: parse() split into readBackPattern() and groupFor() to get under the complexity thresholds, the freeze loop switched to array_keys(), and $at renamed to $moment. Two StaticAccess findings got a reason instead of a fix, since fromProperty() is a named constructor.

Splitting parse() broke it, and the unit tests caught it in the same minute: {seq:5} captures as seq:5, not seq, so the raw capture left every padded sequence unrecognised and parse() answered null for a value the same format had just rendered. Three tests went red on their own assertions. That is what the round-trip test exists for.

Merged on local green per the verify-locally policy.

🤖 Generated with Claude Code

@rubenvdlinde
rubenvdlinde merged commit e7ac79d into development Sep 16, 2026
8 of 9 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 0f233a4

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
check-l10n-js
composer ✅ 174/174
npm ✅ 653/653
app:check-code ⏭️
info.xml
REUSE
lockfile sync
PHPUnit
Newman
Playwright ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it on any branch from the Actions tab, or locally with npx playwright test.
Hydra gates

Quality workflow — 2026-09-16 05:27 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 9da0e45

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
check-l10n-js
composer ✅ 174/174
npm ✅ 653/653
app:check-code ⏭️
info.xml
REUSE
lockfile sync
PHPUnit
Newman
Playwright ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it on any branch from the Actions tab, or locally with npx playwright test.
Hydra gates

Quality workflow — 2026-09-16 05:41 UTC

Download the full PDF report from the workflow artifacts.

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