feat(identifier): a case number from a named sequence and a format, declared on the property - #3785
Conversation
…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.
Quality Report — ConductionNL/openregister @
|
| 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.
Quality Report — ConductionNL/openregister @
|
| 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.
Verification result
What the whole-tree phpmd caught that diff-check could notSeven findings, all this change's own, because Splitting Merged on local green per the verify-locally policy. 🤖 Generated with Claude Code |
Quality Report — ConductionNL/openregister @
|
| 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.
Quality Report — ConductionNL/openregister @
|
| 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.
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, andSequenceService::reserveNext()already hands out numbers under a row lock inside a transaction for the declarativesequencecalculation 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 ownnext_value < ?clause. A plainSETwould 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. AresetOn: yearwhose format never renders{year}works for a year and then re-issues every number from the year before. The refusal extendsPropertyVocabularyException, 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.
sequenceformat{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 literalresetOnnever(default) oryearPOST {"title":"A new case"}answers{"title":"A new case","identifier":"Z-2026-00001", …}.Z-2026-00120makes the next generated caseZ-2026-00121. A value the format does not recognise is left alone and moves no counter.ComplaintService::generateComplaintNumberin 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:strictandnpm run lint: results in the comment below.testAnUpdateThatChangesTheIdentifierIsRefusedon its own assertion; makingparse()ignore the year in the value reddens the round-trip test andtestTheParsedPeriodComesFromTheValue, each on its own assertion line rather than on setup.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
ObjectEntityrather than a double, becausesetObject()is answered by the Entity base class's__calland 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