Release: merge development into beta - #3980
Open
github-actions[bot] wants to merge 338 commits into
Open
github-actions[bot] wants to merge 338 commits into
github-actions[bot] wants to merge 338 commits into
Conversation
…ink exposes (#3937) Rows 2.48 and 13.36. Row 5.16, the party relationship, is named as not started rather than half-built. The affected set is not a second walk: it filters the bounded graph's answer, because two walkers would drift and the second would be the one nobody bounded. The prune is an input AND an output, and it recomputes reachability — dropping the edges while keeping the nodes would be a prune that reports a cut and changes no result. Truncation is passed through, never recomputed. Exposure narrows and never widens. A link is not a grant: handing over whatever a schema author listed would turn every relation into an access decision made by whoever wrote the schema. The design's two sentences only look contradictory, and the reading is now written down — the intersection is with the PROPERTY rules, so a link can carry a reader to a record they could not otherwise open and can never show a field their own rules withhold. A property outside the set reads as WITHHELD, not absent. An undeclared exposes narrows nothing; a present-but-empty one exposes nothing, and those are different statements.
…ule (#3938) openregister#3934 found that MagicFacetHandler returned the distinct values of governed columns to everybody who could list the register. That closed one path. It did not answer the question the path raised: how many other places turn a column into a summary, and do any of them ask. Derived from source rather than from a remembered list, 18 paths match the shape. Three live ones leaked and are fixed here. AggregationRunner gates on list permission for the schema, which is a different question from whether the caller may read the property being summed. A SUM over a salary nobody may read is the salary total, and a groupBy's keys are the distinct values of the column. ViewPresentationService draws one kanban column per distinct value of groupByField, so a governed grouping property becomes a row of headings naming every value. The cards inside are stripped correctly by the render path, which is what made it hard to notice: the board looks empty and correct while its headings are the leak. FacetHandler advertised facetable fields without asking. That is the first half of the leak and the easier half to miss, because naming the field invites a caller to ask for its buckets. A withheld summary is absent rather than zero. Zero is an answer and a wrong one: it says the value does not occur, and a reader cannot tell it from a real zero. Four facet handlers matching the shape are never instantiated. They are asserted to stay dead rather than guarded, because guarding them would raise the count of paths fixed while protecting nothing.
…ved it (#3939) A calendarDays term that ended on a Sunday ended on a Sunday. rollToWorkingDay is none, next or previous on the SLA shape, and an unknown value is refused rather than read as none: on a deadline with legal effect a silent default is the worst kind. The roll answers what it did, not just where it landed. A handler looking at a term that ends on Tuesday has to be able to read that Monday was Tweede Paasdag; a date that moved with no explanation is one somebody will challenge and nobody can defend. The name comes from the calendar's own rule. This code knows one name, weekend, because it is the one rule it decides itself. It is applied in recompute(), the single place fire_at is set, so arm, extend, supersede, suspend and resume all get it and none of them can forget, and the escalation ladder is measured against the rolled moment because that is the deadline the term actually has. TermDiagnostic stops refusing a roll. It refused deliberately: the engine had none, and a diagnostic that applied one would have printed a moment the arm path never produces, believed precisely because it is the diagnostic. It now calls the engine's own roll rather than walking the calendar a second time. Nothing is switched on. The default is none, no shipped calendar changed, and the migration leaves every armed timer with the deadline it has: rolling existing terms would move deadlines with legal effect, retroactively, without anybody deciding to.
…eads (#3941) flow_register.json declares scope: private on the flow schema. Flows live in the native openregister_flows table, and MigrateRegisterFlowsToTable drained that register into it because every subsystem reads the table and nothing reads the register. So the control was declared on the store that had been deliberately emptied, and reported as done. A reader of that declaration would have concluded a colleague cannot run a flow they do not own. What actually protected a run was flow.run, seeded @authenticated, plus an organisation check — on a single-organisation instance, any signed-in user running any flow. A fourth path the original task did not name, FlowController::run(), asked for nothing else. Reading through the drained store is refused: that store was emptied on purpose, with measurements. So one resolver answers for all four paths, over the store they actually read, and the declaration says what it governs. Administrator, owner, or the narrowable flow.update right. An unowned flow is refused to everyone, the administrator included, because the engine will not dispatch one either and test() executes synchronously. This does not change the default posture: flow.update is seeded @authenticated too. What changes is that narrowing it now governs every run path instead of one. Task 9.2's untrue sentence is quoted and replaced rather than ticked.
…e the save path enforces (#3943) Builds the endpoint task 3.2 was blocked on, then closes 3.2. GET /api/objects/{register}/{schema}/{id}/reference-options?property=<name>, declared before objects#show because {id} matches [^/]+ and the generic route would otherwise swallow the longer path and answer 404 for an endpoint that exists. That ordering is asserted, because the symptom is a 404, which is exactly what a wrong URL looks like: an e2e written against it would report endpoint missing and send somebody to the wrong file. It calls the same resolve() the save path calls. A picker that offers one set while the save path accepts another is two evaluators of one rule: the user picks what the form offered and the server refuses it, or the form offers something the server then accepts and should not have. No options is not every option. An unresolved operand answers an empty list and names the property it waits for, with a 200, because the request was fine and the answer is not yet. Returning the unfiltered set would show every contact in the register to somebody who had not yet chosen an organisation. _draft merges over the stored record, because the case a picker exists for is a form being filled in. A limit of zero means the default rather than LIMIT 0, which is an empty page with a 200 and no explanation, and a page is capped so a picker cannot become a bulk export. The search runs with RBAC on: a picker is not a way to see objects you may not see. The e2e is written and tagged, not run: no Playwright runner on this host. It asserts the status of every call, because an earlier spec in this change guessed a URL and the 404 would have been skipped by the suite's own old-build guard, reporting green while asserting nothing.
A citizen who edits the subject line loses the [ZAAK-...] tag the inbound job matches on, and the reply lands nowhere. In-Reply-To and References survive an edited subject, so those are read: the direct parent first, then the ancestry walked from its last entry, which is the nearest ancestor. Nothing is ever guessed. No fuzzy match, no prefix match, and the subject tag is deliberately not read, because a wrong guess files one citizen's reply on another citizen's case, where that case's handler reads it and the only sign is a letter that makes no sense there. A chain naming two objects resolves NEITHER and names both, since somebody replying about one case while quoting a mail about another hands us both and picking either is a coin flip with a disclosure on one side. A reply with no usable reference is unthreaded, which is a named answer rather than an absence: it is real, it arrived, and a person has to see it. Only a threaded result may be filed without one.
…not stay silent (#3944) Taken after checking both candidates: end-date-roll-on-the-calendar is not free, build-orseams merged it as #3939. This builds the vocabulary and the report and nothing that touches XML. They are the two pieces everything else rests on and the two that can be got wrong invisibly: a mapping each direction keeps its own copy of drifts until a file stops round-tripping through its own product, and a report that loses something quietly is the failure the import requirement names in its own words. The import table is NOT the export table flipped. switch and route both export to an exclusive gateway, so a flip resolves the collision by array order and turns every imported route into a switch. A test asserts the flip and the declaration disagree, so nobody simplifies it later. Three verdicts and no fourth. An entry with no element id is refused by the report itself. An approximation counts as a loss, because it is the verdict most likely to read as fine. strict fails on a refusal only. And no type is ever guessed from a task's name: a flow that runs something because a box was labelled "send email" is a flow nobody authorised. The OMG XSD set is deliberately not vendored: it is a licence-checked artefact that wants a person who can say yes to the licence.
…nto the spec (#3917) The notification engine spec described preferences, templating and queueing, but not the three rules the recent work landed: a channel an administrator forces, a message that carries a send-at, and a reply threaded by its headers. Each is stated with the decision a reviewer would otherwise have to reconstruct from the code: - forcing ADDS to the resolved preference rather than replacing it, sits as a layer above the user's own value in the existing resolution, and is not a second dispatcher - a force with no reason is refused at save, and an internal kind aimed at an outside recipient returns a named refusal, because an empty channel list is what an unconfigured kind already produces - a scheduled message is claimed by comparing state AND attempt count, so two sweeps cannot both send one letter bearing one reference number - a reply threads only on an exact Message-ID match; a chain naming two objects threads onto neither, and a reply matching nothing is named unthreaded rather than guessed onto a case The requirements sit inside the ## Requirements section: appended after it they parse as invisible to validate, list and archive.
…ther than intentions (#3940) A satisfaction survey is a thing, not a rating field on a closed case. Four schemas: the survey and its questions, the invitation that asks it, and the answer set that comes back. The invitation is separate so that "who was asked and never answered" is a question with an answer. Every rule here is a promise made to somebody who cannot read the code, so each one is a refusal that carries a sentence: Anonymity is decided at creation and cannot be changed in either direction. Switching it on hides answers somebody has already acted on by name, which does not unread them. Switching it off names respondents who answered on a promise. An anonymous survey withholds its answers below a minimum and shows the count with the reason. An empty result reads as "nobody answered", which is a different and false statement, and one somebody would report on. The anonymous export has no respondent column AT ALL, not an empty one. An empty column is an invitation to a join: the shape of the file says that is what goes there, and somebody fills it from the invitation list in the next sheet. The answer set drops the key rather than emptying it, for the same reason: absent says the promise was kept, empty says nobody has filled it in yet. An expiry that will not parse is treated as expired, not as no expiry. Reading it the other way turns one malformed timestamp into a link that works forever. A missing required answer names the question. "Submission failed" sends somebody back to a form of twenty questions to find the one. A fatigue block is recorded with its reason and counted per respondent across every survey. Somebody asked four times in a month does not care that it was four different surveys, and a gap in the response data with nothing beside it reads as nobody having been asked. Tasks 1.1, 2.3, 3.2, 4.2, 4.3, 5.1, 5.2, 5.3, 6.1 and 6.2 of survey-object.
…3945) hoursPerWorkingDay exists so hours and business days are commensurable, and it stays for that. It cannot say WHEN the clock runs, so an hours term was computed from a single opening minute and a day length. That is right only for an organisation whose day is one unbroken block, and wrong by the length of the lunch break for a counter that closes at midday. A calendar may now declare serviceHours per weekday in its own zone, and an hours term advances only inside them. A calendar declaring none behaves exactly as it did, which is what lets this land without recomputing live terms. Three refusals at write time, each naming the weekday. The one that matters is the overlap: it double-counts its overlap, so every hours term on that calendar fires early, for everybody, every time, and the fired term looks exactly like a correct one. A backwards window and a window on a day the calendar does not work are refused rather than ignored, because dropping the latter silently leaves somebody believing the counter is open on Saturday. The walk is bounded and running out throws rather than returning the cap's date. A term that silently lands a year out is indistinguishable from a correct one, and a statutory deadline is computed from it. hoursPerWorkingDay is derived from the windows so the two cannot disagree. The spec's own scenario for this change is off by an hour: a 4-hour term armed on Friday at 16:00 against a 09:00-17:00 calendar lands on Monday at 12:00, not 11:00. The test follows the arithmetic and says so in its docblock rather than pinning the wrong behaviour and calling it coverage. Flagged for confirmation in the PR body. Tasks 1.1, 1.3, 2.1, 2.2, 2.3 and 4.1 of service-hours-and-repeating-reminders.
…may read it (#3947) openregister#3934 and #3938 left this named and undecided: the OpenAPI description and the GraphQL type mapper describe a schema's shape, including properties the caller may not read. A field name is information. onderzoek_integriteit, schuldhulpverlening, bijzondere_bijstand: the name alone says what category of fact is held, and on a record about one person it says the fact is held about them. A property carries an authorization block or a scope precisely because it is sensitive, so the set of governed names is by construction the set most worth not printing. The objection is that a schema is a contract and a contract should be stable. That is what settled it, and it settled the other way: the API never returns a property this caller may not read, so describing it promises a field that will never arrive. Leaving it out makes the document more truthful, because it describes the API this caller actually has. Its example and enum leave with it, since an example is a sample answer and an enum is the set of permitted answers. Required drops names the document can no longer mention, because a required list naming an absent property is not a contract anyone can satisfy. The omission is a count, never names. Naming them would be the leak with an audit trail attached; saying nothing would leave an integrator unable to tell a complete schema from the part they are allowed to see. An administrator receives the complete description, because they already bypass property-level reads everywhere else and making this the one place they cannot see the schema would be a second answer that drifts. Also narrows the fail-closed path to the property rather than the schema. One governed property made a whole schema governed, so an unresolvable rule withheld every property including ones nobody restricted, which protects nothing and removes the document.
) Everything that does not need the vendored OMG schema set. Two lines of the spec do need it — "every exported file validates against the BPMN 2.0 XSD" and the importer's validation before mapping — and neither is met. The weaker checks that stand in their place are stated in the docblocks and in the tests rather than implied, and the mapping report is NOT offered as a substitute: a test asserts a non-XML file produces no report at all, because a report over a malformed document attributes XML problems to process constructs. switch and route both export to an exclusiveGateway, so every node carries its own type and config in extensionElements and the importer prefers that over the element kind. Mutation-checked: letting the kind win brings route back as switch, and the flow still looks right. The importer never guesses a type from a name, lays out a file with no diagram rather than piling it at the origin, refuses two processes rather than taking the first, and keeps external entities off. One defect caught before it shipped: I wrote FlowService::create(), which does not exist. php -l cannot see it and a mock invents whichever method it is asked for, so a controller test would have passed over a fatal. Fixed and pinned by a test that reads the real class.
…3950) Two things the serialisers landed without. The dependency-direction check was the one unticked task in the change's own list. Interchange is a boundary, not an execution semantic: if a run path ever asked the BPMN code a question, the standard's vocabulary would start deciding behaviour, and the next interchange change would be a change to the engine. The test holds it both ways — the engine may not name the Bpmn namespace, and Bpmn may not queue, advance or fire. A sequenceFlow whose sourceRef or targetRef names nothing in the process is not a slightly wrong diagram: every modeller refuses the whole file, so one edge left behind by a deleted node turns the export into something nobody can open. The engine refuses a dangling edge at build time, but a document assembled from a stored node list can still carry one, and the export is where it becomes fatal.
#3951) * feat(archival): the anonymisation act refuses rather than guesses, and can be finished Tasks 3.4 and 3.5, and the shape 3.2 and 3.3 plug into. Four refusals, because this act is irreversible and reaches several stores, so a best effort is the wrong shape: A record under a legal hold is not anonymised, and the refusal names the hold. A hold says somebody may still need the record as it is, and as it is includes the name. A record already anonymised is refused, naming when and under which profile. Running again changes nothing it has not changed, and under a rotated salt it would give the pseudonyms new values, so rows that used to join would stop joining with nothing on screen to say so. A record left half anonymised is resumed rather than restarted, and only under the same salt. The marker carries a fingerprint of the salt, not the salt: a resume under a different one refuses rather than leaving one record carrying two families of pseudonym. A plan that would change nothing is refused. Marking a record anonymised while it holds every value it held before is the instrument lying about the thing it measures. All or nothing is achieved by preparing every target before applying any, so a search index that cannot be reached stops the act while the record is whole. A failure BETWEEN writes is the honest remaining case: the stores are separate and no transaction spans them. The message says exactly that rather than implying a rollback that did not happen, names what was written, and says the record must not be treated as anonymised until a resume finishes it. A mutation interleaving prepare and apply reddens the "nothing is written" assertion, which is the test that makes the two-phase split load-bearing. * feat(archival): the trail keeps the fact of an anonymisation and loses the values Task 3.3's redaction half. Every write leaves a diff on the chained trail holding the value before and the value after. An anonymisation that changes the payload and leaves those diffs alone has removed the citizen's name from one place and left it in every historical row of the same record, where the trail shows it beside the record it was removed from. That is a rename with a receipt. Both sides of a redacted property go. Keeping old because only new matched the payload is exactly how the name survives: the value a record used to hold is the value being removed. The shape stays. The property name, the untouched properties, the timestamps and the actor remain, and a new entry records the profile and the scope. An auditor can prove the act took place and prove which fields it covered, and cannot recover the person from it. A redacted value is marked rather than emptied, because "removed by an anonymisation" and "was empty at the time" are different facts and only one is about the citizen. The act checks itself: leftovers() reads the redacted diff back and looks for the removed values, because a redaction that missed a nested copy would report success while the name sits one level down, and the trail is the last place anybody would look for it. * feat(archival): the sweep refuses one record at a time and counts the refusals apart Built last, on purpose. Every refusal in AnonymisationRun exists for this class: a person running one record can read an error and decide, a sweep cannot, so a sweep that resolves an ambiguity by guessing resolves it the same wrong way across every record on the instance before anybody notices. Three shapes were available and two are worse. Stopping the batch on the first refusal lets one held record block a retention obligation for everything else. Skipping silently means the reasons never reach anybody while the sweep reports a clean run over records it did not touch. So it refuses per record, keeps every reason, and carries on. The two counts are separate. "412 anonymised" and "412 anonymised, 9 refused" are different sentences, and a sweep that adds them together says neither. The nine are the ones somebody has to do something about. It never retries a refusal on its own. A refusal is a decision waiting for a person: a hold to be lifted, a schema to be corrected, a salt rotation to be reckoned with. Re-attempting it nightly turns a decision into noise and the record still is not anonymised. A candidate with no uuid is refused, because an anonymisation nobody can point at afterwards is not auditable.
Integriq's registry-backed-field-source built the resolver and has been waiting on this key. A correction to what the waiting was. The integriq tasks file, and my own first measurement, said the key would fail a schema save. It would not: assertKeysAreInTheVocabulary skips every x- prefixed key, so x-openregister-property-source has always saved. What it could not do is be discovered, because vocabularyKeys never published it, and nothing checked its shape, so a provider of 7 or a mode of livee saved just as cleanly as the real thing. That is the same failure the concept-scheme binding hit. The shape is integriq's, adopted rather than invented: provider, config, mode, taken from the change where the meaning was defined. The mode defaults to live and default is asked for by name, because they are different promises to whoever reads the record later: live means the value is looked up when it is used, default means the provider only supplied a starting value a person may change. A property carrying both this key and x-openregister-object-source is refused rather than ranked. They differ by one word and by their entire blast radius, and the wrong half of a guess serves an entire register from somewhere unexpected. An existing test asserted the opposite and was right at the time: it said publishing the key would be this layer inventing semantics for a key it did not own. That reasoning is honoured rather than overruled, because the meaning is now defined elsewhere and adopted here. Its fixture was never a shipped shape; no register file in either repo carries this key.
…hat nobody imports (#3949) survey_register.json shipped with no repair step, and appinfo/info.xml named none, so the surveys register and its four schemas existed only in the source tree. occ upgrade reported success and occ openregister:descriptors:list would have reported surveys ABSENT. This is the second time: the comment beside ImportFlowRegister in info.xml records the first, eight of fifteen registers missing, found only because two unrelated e2e suites died on a register slug nobody could find. So the step comes with a gate. Every descriptor whose x-openregister.type is not mock must be named by a repair step that info.xml runs. The control holds: all eleven other core descriptors have one and only the six mock ones do not, which is what makes the survey case an exception rather than a convention nobody follows. The gate reads the path a step IMPORTS, not the text of its file. A substring search over the source passed on a step whose REGISTER_PATH had been repointed at another descriptor, because the class comment still named the old one: the sentence explaining what a step does outlives the constant that does it. Found by mutation. Not asserted, and reported instead: ImportCredentialBrokerRegister pins 1.0.0 against a 1.4.0 register and ImportFlowRegister pins 1.4.0 against a 1.3.0 register. Both predate this change, and a gate that fails on inherited debt teaches everybody to skip it.
A saved view answers a question; nothing said anything when the answer got large. An alert declares an operator, a threshold, who hears it and how often it is evaluated, and every refusal names its field, because a 422 that does not say what to fix sends somebody back to a form with five inputs. It fires on the crossing, not on the state. A count above the line says so once and stays fired until a sweep sees it back, then re-arms silently: nobody asked to hear that a backlog cleared, and firing on the state would page a team lead every fifteen minutes for as long as the backlog stood, which is how an alert becomes a thing people filter out of their inbox. The count is taken as the view's owner. A shared view alerts on what its owner may see; counting as the system would turn a threshold on a shared view into a way to learn how many records sit behind a filter the reader is not entitled to. A view whose owner no longer exists is skipped rather than counted as the system, and a count that cannot be taken leaves the state alone rather than re-arming a fired alert. The pass is bounded at 200 views, oldest evaluation first, so a thousand due views take five passes and none starves behind a busier neighbour. The dispatch is an event, not a notification, and the difference is written down: every sender here takes an ObjectEntity and builds a deeplink from it, and a view alert is about a number. Inventing an object to satisfy that signature would put a fabricated record in the link somebody is told to click.
A leaf has two halves: a descriptor the server registers and a bundle the browser loads. Only the first was checked. So a render-surface descriptor from an app shipping no leaf bundle reached capability discovery, getLeaves() returned it, the gate went green on both halves, and the surface rendered nothing on every consuming page. Nobody was told, because nothing had failed. Measured on the development instance across 35 installed apps: 5 declare a render surface, 2 ship a bundle, so 3 are dark today. One of the three did build a bundle and named it hermiq-agent-leaf.js, while the loader reads hermiq-leaves.js, so the work was done and the filename made it invisible. That is why the refusal names the exact file the app must produce. LeafBundle becomes the one answer to whether an app's leaf can render, used by both the registry and the script listener. Two copies would drift, and the registry accepting a leaf the loader never puts on a page is the failure being fixed. Three cases are deliberately not refused: a leaf with no render-surface kind has no client half; a built-in leaf rides OpenRegister's own bundle; and a disabled app is already reported unusable by describeForCapabilities, which is right because enabling the app fixes it while a missing bundle never fixes itself. An existing test caught that last distinction.
) Corrects openregister#3954, merged this evening, before it broke a working feature. #3954 skipped registration for a render-surface leaf whose app ships no js/<app>-leaves.js. That was unsound, and hermiq is the proof: it ships no hermiq-leaves.js and its leaf is not dark. It loads its own render-registration bundle on every Nextcloud page with Util::addInitScript, precisely so it runs wherever another app renders the integration registry. The refusal would have taken that down. The lesson is about what the registry can know. Whether a bundle reaches the page is a fact about the page; the registry sees only the filesystem. The absence of one conventional filename is not proof of absence, because it is one convention out of at least three and the app chooses which. I had measured two of the three and called the answer complete. The loud, actionable error stays, because naming the exact expected file is what turned hermiq's invisibly-named bundle into a one-line fix. Only the skip goes. A sound refusal needs the descriptor to declare that it relies on the shared entry, so an app loading its own bundle is never refused. That declaration does not exist yet and is named in the tasks rather than guessed at here.
#3956) openregister#3954 refused a render-surface leaf whose app shipped no js/<app>-leaves.js, and was wrong twice in one measurement: hermiq and decidiq ship no such file and are not dark, because each loads its own registration bundle on every page with Util::addInitScript. #3955 downgraded that to a report before it broke them. The rule that came out of it is that refusing on filesystem evidence is unsound, because whether a bundle reaches the page is a fact about the page and the registry sees only the filesystem. So a descriptor now says which of three conventions it uses: the shared leaves entry, its own script, or already present. Only the first is verifiable, because it is the only one where the platform does the loading, and only a leaf claiming it is refused when the file is absent. Silence is not a claim. A descriptor that declares nothing registers, as #3955 left it, because every descriptor written before this says nothing and refusing silence would re-create the original failure wholesale. A fourth convention gets its own name rather than being folded in: own-script means the app guarantees it, and a mechanism the platform could verify deserves naming, because the value of the list is that one entry is checkable and the others are trusted.
…ds on (#3957) The task asked for two indexes; the source needs one, and saying so is the point. FlowTimerMapper has exactly two calendar reads, findOpenByCalendarSlug and countOpenByCalendarSlug, and both filter on the same pair, calendar_slug and state. One composite index serves both. The second index was to be on organisation, and no query filters on it, so it would have cost a write on every timer armed and been read by nobody. calendar_slug leads because it is the selective half: one calendar out of many, against a state that is two values. The existing or_flowtimer_due_idx on (state, fire_at) does not serve these reads for exactly that reason. It changes the cost, not the answer. Without it the job paged the open timers by id, which is an index read with a resumable cursor over a small set, not a scan of every timer ever armed. A speed-up on a correct job. Also records that section 2 of relations-that-travel must not be built here: pipelinq already ships a relationship schema carrying fromContact, toContact, fromType, toType, type, inverseType, category, notes, startDate, endDate and strength. A second schema for one concept is the costliest kind of duplicate, because two schemas mean two sets of stored rows and nothing reconciles them afterwards. The validation and the direction-aware read are genuinely unbuilt and belong to pipelinq, which owns the schema.
Both are satisfied by pipelinq's components.schemas.relationship, which carries fromContact, toContact, fromType, toType, type, inverseType and the period. Provenance, the one part it lacked, was added there in pipelinq#1981 as an enum defaulting to the weakest value. Recorded here so nobody builds the duplicate later. If a reader is tempted to add partyRelationship, the answer is in pipelinq's schema, and the reason not to is that two schemas for one concept mean two sets of stored rows with nothing reconciling them.
…tion is verified (#3958) A consuming app cannot hard-depend on OpenRegister, so every consumer invented the same guard: class_exists, then run(), else call the operation plainly. The fallback is the bug. It does not decline to elevate, it runs the identical write as whoever is signed in and returns the same value the elevated call would have. Nothing throws and nothing logs, so the write either records the wrong principal or fails a permission check somewhere far away for a reason nobody connects back to a missing class. It also makes the codebase unsweepable. A reviewer asking which writes run as the system cannot answer statically, because a call site naming the context may or may not have elevated, and a scan for the idiom counts the degraded path as elevated. That ambiguity is what stopped integriq's permission sweep: the safe subset could not be identified, so nothing could be restricted. assertSystem() says the same thing and refuses to be ambiguous. Either the operation runs elevated or it throws, naming the write. The elevation is verified rather than assumed, on BOTH sides of the operation. Before, because an elevation that never applied is the ordinary failure. After, because one that stopped applying part-way is the dangerous one: the write has already happened, and checking only up front would call it elevated. An earlier draft checked class_exists(self::class) and threw when false, which cannot happen: a class that does not exist cannot run its own static method. That guard was dead the day it was written, and a dead guard is worse than none because it reads as a check and a later edit deletes it with every test green. This lane has now removed two of those, so the replacement is tested by breaking the elevation rather than by breaking the guard.
…nge (#3960) * feat(audit): a write made with a token names the token, its owner and its consumer The question the candidate actually asked is "which koppeling wrote this field", and the trail could not answer it. It records what changed and who was logged in; when the caller is a service account shared by four integrations, that is not an answer anybody can act on. - TokenResolver reads the session's app password back to its token record. An interactive login has no app password, which is the distinction the whole field rests on: a row naming a token has to mean a machine wrote it. - The consumer is matched on the Nextcloud user a consumer already declares, not on the token's NAME. An app password's name is free text its owner can retype, and an attribution a rename silently redirects is worse than none. Two consumers sharing one user resolve to neither, for the same reason. - AuthorizationService claims the issuer for a JWT call. It is the only place that knows which consumer presented the credential, and a JWT leaves no app password for the resolver to find, so without the claim every koppeling authenticating that way writes rows that cannot say who wrote them. - TokenAttribution writes the triple twice, the shape PurposeAttribution established: sealed inside resultSummary, which the hash chain covers, and projected onto the new consumer column, which is indexed so "everything this koppeling wrote last month" is a lookup rather than a scan of the largest table in the app. disagrees() makes the column's unsealed status checkable. The token VALUE never enters any of it. The trail is shipped off the instance by design and kept for years; a credential in it is a breach waiting for somebody to grep for it. TokenResolverTest holds that as an assertion rather than an intention. No payload, per D-4: the before and after already on the entry answers the question a stored body was being kept for, without a second copy of personal data with its own retention argument. carriesPayload() makes the prohibition provable, and the test for it carries a control that proves the checker can find a payload nested two levels down, so the absence it asserts is a real one. 18 unit tests, phpmd and the diff check both clean on every file touched. * feat(audit): reported content keeps a copy that removal does not destroy The proving system is forgejo's shadow copy: content reported for review is frozen, so deleting it does not delete the evidence. - The copy is taken when the report is FILED, never when a removal runs (D-5). A copy made at deletion time races the deletion, and the content removed fastest is usually the content somebody most wanted the evidence of. - openregister_content_reports holds the frozen fields, a SHA-256 over them and the copy's OWN expiry, three years by default. It cannot inherit the object's retention: the sweep that deletes the content would take its evidence along. object_uuid is a uuid, not a foreign key, because the row outlives the object. - Reading a copy is the reviewer group's, content-reviewers by default and deliberately not admin. The group is pinned on the report at filing, so widening the configured group later does not widen access to copies already taken. A group lookup that fails refuses. - ContentReport::jsonSerialize() leaves the copy out. That omission IS the access control: the copy has its own endpoint behind its own check, and a copy in the serializer would leak through every list of reports. - ContentReportRemovalListener, on ObjectDeletedEvent, notes the removal on each report and writes an audit entry naming the copies, so somebody reading the trail can reach the evidence too. Unreported deletes write nothing, and a failed note never blocks a removal somebody may be required to make. - POST /api/content-reports is open to any authenticated caller: reporting must not be a privilege. The list, the report, the copy and the review outcome are reviewer only. 24 unit tests. phpmd and phpcs clean on every file this commit touches; the diff check's one NEW finding (a test property docblock) is fixed here. * feat(audit): a security-relevant setting change is announced to the administrators Redmine's security_notifications is the proving system: it tells somebody at the moment a security setting changes. Announcing is not recording (D-6). The record belongs to settings-change-audit; a small beheerteam does not read a trail every morning, and a switched-off access check is the change they need to hear about that day. - SecuritySettingRegistry is the marker: sixteen settings, each with its label, its default and whether it holds a secret. A list rather than an attribute scattered through the settings code, because "which settings will page the beheerteam" needs one answer somebody can read. - The default is why saving a settings page for the first time is silent. An unset value and its default are the same value, and treating them as different would announce a change nobody made. - A secret never reaches the notification, not even its parameters. Nextcloud stores those in its database and can mail them, so masking at render time would leave the credential in a table and an inbox. isSecret() also catches any path naming a password, secret, token or key, because the two mistakes do not cost the same. - The snapshot is taken before and after the save inside the handler, not derived from the request: a setting the request omits keeps its stored value, so comparing against the request would both invent changes and miss real ones. - Notifier renders the subject. Without that case prepare() throws on an unknown subject and the announcement is dropped, which is the failure mode this whole requirement exists to prevent. 110 unit tests green across the notifier, the announcer and the settings handler. The e2e spec is written and tagged, including a control that an unmarked setting announces nothing, and left for the nightly run. * fix(audit): the inherited migrations hand the schema back Both predate the guard that says a changeSchema() must return $schema: a null return drops the shared snapshot and makes the next migration re-introspect the whole database. Fixed on the way in rather than landed as two new violations of a test that was already failing, where they would have hidden inside a red nobody reads twice.
AnnotationNotificationDispatcher had `if (count($recipients) === 0) { continue;
}`. No log, no counter, no complaint. And declared groups ship EMPTY on purpose
across this fleet, because an empty group denies everyone except admins and
object owners, which is the right default. So on a fresh install a correctly
written rule addressed to a declared group resolves to nobody and reports
exactly what it would report having reached everybody. Every annotation added on
top of that reaches nobody.
Two halves, because the two cases are genuinely different.
At declaration time, a recipient that can NEVER resolve is refused: `groups: []`
and `users: []` name nobody structurally, so no instance state makes them match
and they are stubs or typos. A declared group that happens to be empty today is
NOT refused, and there is a test asserting that, because refusing it would fail
the import of every correctly written annotation on a fresh install.
At dispatch time, a rule that reached nobody is recorded. Only when the parties
path reached nobody either: dispatchToParties now returns a count rather than
void, because a rule addressed to parties legitimately resolves to zero account
recipients while still reaching people by e-mail, and reporting those would be a
false alarm on every party-addressed rule.
It is said ONCE PER RULE per run. A sweep over four hundred objects with one
unstaffed group would otherwise write four hundred warnings, and a log nobody
can read is the same silence with noise in front of it. The rule count and the
occurrence count are kept apart: one rule failing four hundred times and four
hundred rules failing once are very different problems. The report is returned
as well as logged, because a finding that exists only in a log file is findable
by whoever already suspects it.
NO FALLBACK RECIPIENT, and that is an argument rather than an omission. Routing
every misconfigured rule to administrators would mail them until they stop
reading any of it, and some of these messages carry case content addressed to a
group chosen precisely because it may see that case. Making the silence visible
is a smaller change than deciding on somebody's behalf who may read a
notification.
…h are right (#3962) The same declaration was read two ways: a schema cascade denies on an empty action, a property block admits. Reported as an inconsistency with one side failing open. Measured, it is not. A schema cascade is the last word, so an empty list can only mean denied. A property block is a narrowing on top of the object cascade, so an action it does not name has no opinion at that layer and the object's own rules still have to pass. getUnauthorizedProperties only consults properties carrying a block, and one it does not refuse is still written through the ordinary object permission check. So the property side is not a fail-open to anyone; it is no extra restriction here. The word accessible in that comment was wrong and is what made it read as a leak. Harmonising would cost more than it buys, and that was measured before deciding: across the installed fleet there are 8 property-level blocks and all 8 are partial, not one naming all four actions. A fail-closed property layer would make every action they do not name unwritable, breaking all eight in decidiq and stackiq. A guard satisfiable only by breaking what it guards is worse than no guard. What is genuinely sharp is left as a schema author's decision and named where they will meet it: a property restricting read and saying nothing about update can be written by anyone who may write the object, including somebody who may not read it. That is a blind write rather than a disclosure, and which of the two a schema wants is that schema's judgement.
…what would give it one (#3963) openregister#3961 shipped the code with no openspec change written down. This writes it down, and the part worth writing down is what it does not do. RuleReachRecorder::report() has no caller. Measured on parity/round2 at 1a895e0: ten ->report() call sites in lib, all on other classes; the class appears in lib in three files, itself, the dispatcher's property and default, and one comment in the validator; and it has no registration in lib/AppInfo/. The dispatcher builds its own with new RuleReachRecorder() and keeps it private. So the aggregate is not merely uncalled, it is unreachable: the part that says how many rules reached nobody, and whether one rule failed four hundred times or four hundred rules failed once, is discarded with the dispatcher instance. What is real today is the one warning line per rule per run. An administrator finds it by searching for the marker, which means only if they already suspect the problem, which is the wrong audience. The tests on this class should not be read as evidence that an operator is being told, so the class now says that above report() rather than leaving it to be inferred. This is the dark-capability shape we have been closing all night: a method that exists, is tested, reads as a feature in review, and is reachable by nothing. Recording it rather than letting it sit unrecorded because a leaf app worked around it. What would give it a caller, in order of cost: register it as a shared service and read it on the notification settings page, where an administrator configuring notifications is already standing; a scheduled job raising a notification when needsAPerson is true, with the storm question answered first; persisting it so "has this rule been unstaffed for three weeks" is answerable. The settings row is the honest first step, and it is what tasks 3.1 to 3.3 name. REQ-RRN-03 is declared and marked NOT MET on purpose. filinq#1136 answers a different question in the meantime, at read time and on a screen, for the rules that one app declared. That is not a substitute: report() is the platform's answer across every rule on the instance. No behaviour change. openspec validate --strict passes, php -l and phpcs clean on the edited file at 0 errors, the eight RuleReachRecorder tests still green.
…an administration write asks for (#3965) Part 2 of instance-hardening-controls, on top of part 1 (#3850). Two of the five remaining sections: the accepted statement (REQ-IHC-001) and elevation (REQ-IHC-002). Sections 3, 4 and 5 are not built, and tasks.md says so task by task rather than leaving half-written code behind. THE STATEMENT CARRIES A VERSION. Recording that somebody accepted "the privacy statement" is worth nothing the day the statement changes: the record then says a person agreed to a text nobody can produce. So the acceptance carries the version it was given for, publishing a new version asks everybody again, and accepting a version that is not in force is refused rather than trusted, so a client cannot close the gate on a text the user was never shown. ELEVATION IS THE CHECK A STOLEN SESSION DOES NOT PASS. A Nextcloud session lives for a day and an administrator leaves it open. `POST /api/hardening/elevation` confirms the password of the session's own account, throttled through a new `ThrottledSurfaces::ELEVATION` because a correct guess there buys the right to weaken every control on the report. The period is `admin.elevationSeconds`, a control with an `atMost` floor, because a longer window is a weaker instance. The four administration writes call `requireElevated()` and answer 403 with the period, and the guard fails closed on no moment, an unreadable one and a moment in the future. The account is never read from the request, on either surface: an elevation or an acceptance naming a user id would let one person act for another. Verified: php -l on every changed file; 75 unit tests over the hardening services and the controller, green; mutation-checked by removing the guard from updateControls, which reddens the "setControl was not expected to be called" assertion; openspec validate --strict. The e2e spec now elevates before each write, which is the behaviour change a client sees.
…3964) * wip: work in progress saved when the weekly limit stopped the lane * feat(apphost): a page opens without a session only when the app declares it public A citizen holding a live access link reached a login screen: the page that would render their record is served by the SPA catch-all, and the catch-all requires an account. Making it public would have opened every page of every adopting app at once, so the public surface is a route of its own. `PublicPageResolver` reads the leaf app's bundled manifest and answers one question: did the app declare this path public, with `config.mode: "public"` and a route under `/public/`? Both, or the page stays shut. A manifest that is missing, unreadable or invalid JSON declares nothing. `Routes::standard($extra, publicPages: true)` adds the route, opt-in, after the app's own routes and before the catch-all. The shell carries no record data, only an initial-state key telling the app it booted public. Also openregister#3818: the object share token answered with the whole object, `@self.authorization` included. It now projects through the access link reader, so the two anonymous surfaces publish one allow-list, and the reader projects timeline entries too, because a public entry's text is public and the account that wrote it is not. The leaf half, dossiq declaring its status page, is task 6.1 and its own PR.
… can pass (#4152) * test(rbac): a create rule with a match is evaluated against the incoming object (red, #4094) * fix(rbac): ask the create question about the incoming data so a match can pass (#4094) checkSavePermissions() now builds a transient object from the request body without its @self block, with the caller's active organisation and no owner, and passes it to both create checks. A create is exempt from the private scope gate, as it was when no object was passed.
…t what cannot be built (#4154) * test(rbac): an unsupported match operator is refused at save and denies on list as on find (red, #4089) * fix(rbac): refuse unknown match operators at save, apply every operator on the list and deny what cannot be built (#4089) Schema save refuses a $ operator outside the ten the evaluators handle and an $in/$nin operand that is neither a list nor a $token. The list query now ANDs every operator of a property instead of the first, emits the impossible predicate for an operator or operand it cannot build, and turns $in: [] into a deny and $nin: [] into IS NOT NULL. OperatorEvaluator denies a non-list $in/$nin operand; $nin over a scalar used to grant.
… from (#4157) * test(export): a property filter narrows the export (red, #4088) * fix(export): an export keeps the property filters of the list it came from (#4088) fetchObjectsForExport() skipped every filter that was not an @self filter, so CSV, Excel, JSON and PDF exports, the row count, export profiles and scheduled reports all carried every readable row. Property filters now reach searchObjects(); the route's own parameters and _-prefixed controls do not.
…nges (#4158) * fix(schemas): the schema tool and the source merge classify their changes After #4147 two definition update paths still wrote without the versioning service: SchemaTool::updateSchema() (the agent tool) and the applied update-from-source merge in SchemaImportController. Both now classify the change against the stored definition before it is made, bump the version by the classification, and write the changelog entry after the update. Neither has anybody to answer a breaking-change prompt, so, as on the configuration import, a breaking change is recorded rather than refused. Fixes #4102 * docs(schemas): document the versioning parameter of SchemaImportController
…ithout listing, so their coverage is counted (#4155)
… retire the l10n generator
The secret is written before the metadata is saved, and the save is
where the schema validates. A rename past the schema's bounds with a
rotation therefore changed the secret and then answered 500.
CredentialUpdateRequest::exceedsBounds() checks the brokeredcredential
maxLengths (name 255, each allowedApps entry 64, in characters) first,
and update() answers 400 before anything is written.
The logger fake now records the context, and both failure tests assert
it is exactly the credential id, so logging the exception with its
secret-carrying trace turns them red. The save failure without a
rotation has its own test.
scripts/build-l10n-js.js rebuilt every l10n/*.js from l10n/*.json, but
this repo keeps them as separate catalogues, so a rebuild deleted every
frontend-only string. The generator, its l10n:build and check:l10n-js
scripts and the CI leg are removed; test:l10n:parity still gates the
.js set. check-schema-l10n.js now reads l10n/en.js, the catalogue t()
renders schema strings from; its count and baseline are unchanged.
CHANGELOG.md notes the PUT /api/credentials/{id} change.
…rt-status-codes # Conflicts: # tests/Unit/Controller/CredentialControllerTest.php # tests/Unit/Controller/CredentialOauth2ControllerTest.php # tests/Unit/Service/Credential/OAuth2InstanceClientTest.php
Both allowedApps cases were ASCII, so byte and character counts agreed and a strlen in place of mb_strlen on that bound passed every test. The at-bounds case now uses 64 multibyte characters, and 65 of them is a new out-of-bounds case.
…ationContext Nextcloud 35.0.1 added registerSystemReportSection() to IRegistrationContext, so on stable35 the test double no longer implemented the interface and PHPUnit died with a fatal error before running a test. It is a no-op, like the other registrations these tests do not use.
fix(credentials): start an OAuth2 connection, and answer each refusal with its own status
…nswers 409 (#4174) * wip(audit): findByObjectUntil rewrite for #4161, mid-edit when the lane was killed by the weekly limit; not verified * fix(revert): a revert runs against the real audit table, by slug too, and a frozen PATCH answers 409 (#4161) findByObjectUntil filtered on a column object_id the audit table never had, so every revert answered 500. It now matches on object_uuid and resolves an audit id or a version to the entries after it; revertChanges writes the old values back into the object data. The red test runs the query against the table the migrations build in SQLite (old query: 'no such column: object_id'). RevertHandler accepts the register and schema by id, UUID or slug. PUT, PATCH and POST-patch map ObjectStateWriteException to its declared 409 instead of 403 or a bare 500. * fix(audit): resolve the revert point without an else clause (phpmd)
…ot an update (#4173) saveObject() refused any write carrying a uuid on an append-only schema, and an id in the body becomes that uuid. So every insert with a chosen id was refused, which is every xAPI statement learniq's LRS stores. The guard now asks whether an object with that uuid is stored (unfiltered by RBAC and tenant, soft-deleted rows included, scoped to the register and schema). Stored: refused as an update, as before. Not stored: the write goes down insert-only, so a concurrent insert of the same uuid loses at the _uuid unique constraint with a 409 instead of becoming an update. Assisted-by: Claude Code
…ng the operator (#4178) * fix(schemas): a malformed authorization rule is refused with 400 naming the operator (#4162) The schema validator threw plain InvalidArgumentExceptions, and the schema controller guessed the status from words in the message. The match-operator refusals ('Authorization match for ... uses the unsupported operator') carried none of those words and answered a bare 500. The validator now throws InvalidAuthorizationRuleException (a subclass, so existing catches still hold), and create, update (PATCH) and upload-update answer it with 400 and the message. * wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified * fix(schemas): name the message argument of the new exception (phpcs), and stop tracking the node_modules symlink
#4180) * fix(import): pass 2 of a configuration import keeps the version pass 1 bumped (#4163) importFromJson() imports each schema twice. Pass 1 classified the change and bumped the version; Pass 2 re-imported the same data with force, found nothing to classify, and wrote the incoming (older) version back, so the schema kept 0.0.4 while its changelog named 1.0.0. An import no longer moves a schema's version back: when the incoming version is not newer, the stored one stays, or the classification bumps it. The red test runs both passes over a stateful mapper with the real versioning and diff services. * wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified * chore: stop tracking the node_modules symlink
…ad (#4182) * fix(rbac): a full save keeps a property the writer was not allowed to read (#4170) A property whose authorization.read the caller fails is stripped from their read, so a GET, edit, PUT round trip sent a body without it and the PUT null-fill erased the stored value (humaniq's BSN, IBAN and salary for a manager). PropertyRbacHandler::collectOmittedUnreadableProperties() names the omitted properties the writer cannot read on the stored object, and SaveObject carries them forward with the write-only restore (#463). A writer who can read the property and leaves it out still clears it. * wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified * chore: stop tracking the node_modules symlink
) * feat(export): another app can render the rows it fetched as a PDF (portaliq#765) ExportService::renderRowsToPdf(title, columns, rows) renders caller-supplied rows as a PDF table through the same Dompdf sandbox and under the same MAX_PDF_EXPORT_ROWS cap as exportToPdf(), and reads nothing itself. Portaliq needs it because a resident's scoped collection is not a Nextcloud user's search, so exportToPdf() cannot fetch it. Columns are key => label, or a list of keys that are their own labels; lists render comma-separated, other structures as JSON, every cell escaped and truncated as the existing export does. * wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified * chore: stop tracking the node_modules symlink * refactor(export): the rows section is its own class, so ExportService stays under the method limit (phpcs, phpmd)
… integriq (#4186) * docs(openspec): change expression-value-sources, conditions read integriq's allowlisted value sources (#4169) * feat(rules): a condition can read an allowlisted value source through integriq, and fails closed without it (#4169) * chore(openspec): archive expression-value-sources and fold its requirement into flow-engine
…verdue notice (#4188) * feat(flow): a party is told when their portal task is overdue (#4166) A postBreach escalation rung falls after the deadline and is addressed to the party; the portal reminder listener records it as an overdue delivery (PortalTaskDelivery::KIND_OVERDUE), its message carrying the consequence the case type words. slaBreached still escalates inward only. The rung vocabulary, the fired event and the escalation-ladder register schema carry postBreach and consequence; both new schema strings are in every locale. Also fixes the preBreach reminder itself: the listener checked the timer with method_exists(), which is false for FlowTimer's Entity magic getters, so no reminder ever reached a party outside tests that faked the timer. The new tests use the real FlowTimerFiredEvent and FlowTimer. * wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified * chore: stop tracking the node_modules symlink * chore(l10n): add the two flow strings as appended entries, keep the bundles' order * fix(flow): validate the postBreach ladder with the real schema validator, bump the ladder schema, keep each docblock on its method * fix(flow): document the consequence parameter, capitalise the comment (phpcs)
…ssing halves in eight changes (#4190)
…key (#4177) * fix(rbac): accept a property's authorization.audit flag as a control key `audit: true` on a property's authorization block is the sensitive-field-reveal-audit flag RevealCollector reads, and SchemaMapper::validateRevealAudit() checks its shape. It was not in PermissionCatalogue::CONTROL_KEYS, so Schema::validateAuthorizationRules() read it as a verb and refused every schema declaring it: Invalid authorization action 'audit' in property 'personalNumber'. Seen live 2026-09-29 on a throwaway instance: learniq's LearnerProfile (personalNumber, the BSN, D31) could not be imported, and the learniq register came up without its learner schema. The new test is red before the fix (1 failure) and green after. * fix(tests): the migrated test database speaks Nextcloud 35's schema and result API AuditTrailMapperRevertQueryTest (from #4174) errored on every test in the stable35 CI cell, so every PR since has been red on PHPUnit. On Nextcloud 35 ISchemaWrapper::getTable()/createTable() are typed OCP\DB\Schema\ITable and the real wrapper hands out OC\DB\Schema\Table around the Doctrine table. The fake wrapper returned the bare Doctrine table and failed its own return type. dropTable() returned the Doctrine schema where the interface says self. Past that, NC 35's QBMapper reads rows with IResult::fetchAssociative(), which the result bridge refused. The fake now returns what the running Nextcloud's wrapper returns, and the result bridge answers whichever fetch methods the loaded IResult declares, so the test runs against both the vendored OCP and NC 35. Assisted-by: Claude Code
…4194) * feat(archival): two matters can hold the same record without releasing each other (#4172) retention.legalHold.holds is a list of holds, one per owner key (for example filinq:legalHoldCase:<uuid>). Placing adds or updates that owner's hold; releasing with an owner key lifts only that hold and moves it to history; a release naming no owner lifts every hold, as before. legalHold.active is derived (any hold active), so every reader of it is unchanged, and a stored single slot reads as one hold owned by openregister:manual. LegalHoldService and RetentionService now share LegalHoldLedger; both endpoints take ownerKey. Change: legal-hold-per-matter. * wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified * chore: stop tracking the node_modules symlink * chore(openspec): archive legal-hold-per-matter and fold its requirement into archival-destruction-workflow * fix(archival): no inline if in the hold ledger write (phpcs) * fix(retention): group the legal hold param tags (phpcs) * fix(archival): return early instead of else when a matter restates its hold (phpmd)
… _multitenancy (#4192) deleteObject() forwarded both flags to the delete handler, but the two lookups it runs first did not: the owner lookup for the permission check and the transferred-object guard both applied the session's RBAC and tenant scope. A caller that passed _multitenancy: false (learniq's xAPI document store, answering a cmi5 AU with no session) got "Object not found in magic table" for an object that exists, and the handler never ran. The same gap let the transferred guard miss an object outside the session scope and wave its delete through. Both lookups now use the caller's flags. deleteObjects() already did. Assisted-by: Claude Code
…#4198) * feat(approval): amount tiers can be cumulative, so an amount needs every tier at or below it * chore(openspec): archive approval-cumulative-tiers and fold REQ-010 into approval-workflow * docs(openspec): cumulative tiers is REQ-011, REQ-010 was taken
…urce merge is logged as well as recorded (#4214)
#4217) * feat(extraction): another app can hand in the text it read from a scan (#2033) TextExtractionService::extractFromProvidedText(fileId, text, entityTypes, method='ocr') indexes text another app extracted, such as filinq's local OCR of a scan: sanitised, chunked and stored as the file's chunks, then entity recognition and the risk level when recognition is on. The file must exist and its own content is not read. extractFile() and the new seam share one indexing path, and the metadata chunk now records extraction_method (llphant or what the caller named). * wip: mid-task state when the lane was killed by the weekly limit (29 Sep 16:05); not verified * chore: stop tracking the node_modules symlink * docs(text-extraction): specify text another app hands in for a file (#2033)
…n envelope reaches it (#4216) * fix(encryption): an encrypted property gets a TEXT column, and only an envelope reaches it Updating any object whose schema has an x-openregister-encrypted property failed with "column personal_number ... does not exist" (#4197). The table sync skipped encrypted properties, on the belief that the value lives in an `object` JSON blob column. No such column exists. The write path kept naming the property, so every single-object save failed, and the bulk path, which drops unknown columns, discarded the value without a word: learniq's 203 example learner profiles have no personalNumber. An encrypted property now gets a nullable, unindexed TEXT column, since its value is an opaque envelope whatever its declared type. Existing tables pick the column up through the missing-column sync on first use. The bulk path never ran SaveObject's encryption step, so with a column in place it would have stored plaintext. MagicMapper now encrypts flagged properties at the table boundary for single and bulk writes (idempotent, an envelope passes through), and refuses the write if no encryption handler is available rather than store it in the clear. Fixes #4197 Assisted-by: Claude Code * fix(quality): import RuntimeException in MagicMapper (phpmd MissingImport) Assisted-by: Claude Code
…r its own prefix (#4220) AuditTrailMapper::countByActionPrefix() answers the lifetime count per action for one prefix in a grouped query, and the list filter action=<prefix>.* answers every row of that prefix. portaliq's proof records (DECISIONS row 5) no longer need a full load per verb on every metrics scrape. Assisted-by: Claude Code
…s stored (#4222) * feat(views): a saved view can be shared with a group, and the share is stored View::setSharedWith() had no caller and ViewShareResolver::validateShares() no call site: create, update and patch now pass sharedWith through the validator (400 naming the finding for a group that does not exist) into ViewService, and the edit screen sends sharedWith instead of the sharedGroups nothing read. Change view-group-share archived. Assisted-by: Claude Code * style(views): prettier on the view share modal * style(views): build the group shares before the update payload, so eslint and prettier agree * style(views): name why ViewService::update takes ten parameters Assisted-by: Claude Code
…e empty string (#4224) A non-admin reading an object whose schema has a read rule matching on false got HTTP 500 on PostgreSQL: invalid input syntax for type boolean: "". hermiq's agent rule {"isPrivate": false} hit it on every single-object read through findAcrossAllMagicTables. The query-builder RBAC path bound the resolved match value with the default PARAM_STR. PDO casts a PHP false to '' (true to '1', which pgsql happens to accept), so only rules on false failed. The raw-SQL list path already wrote TRUE/FALSE, so list and single-object reads disagreed. buildPropertyCondition() and buildComparisonOperatorCondition() now bind a bool with PARAM_BOOL: a real boolean on PostgreSQL, 0/1 on MySQL. The operator test's query-builder double returned a string where the real builder returns an IParameter; it now returns an IParameter. Assisted-by: Claude Code
…4226) * feat(views): a saved view can be shared with a group, and the share is stored View::setSharedWith() had no caller and ViewShareResolver::validateShares() no call site: create, update and patch now pass sharedWith through the validator (400 naming the finding for a group that does not exist) into ViewService, and the edit screen sends sharedWith instead of the sharedGroups nothing read. Change view-group-share archived. Assisted-by: Claude Code * feat(views): a write member of a shared view saves its query, judged on what changed The update and patch paths looked a view up as the caller's own, so a write member got 404. They now resolve any view that reaches the caller (owned, group share, public, administrator) and save it under its own owner. The field guard judges only the fields whose value changes, by value and order-insensitive for query keys and shares, so the edit screen's full body refuses nothing a member did not touch. Change a-view-update-is-judged-by-what-changed archived; row srch-saved-shared built. Assisted-by: Claude Code * fix(views): keep ViewsController under the phpmd class-length limit Drops a comment that described a lookup refuseForbiddenViewFields() no longer makes, and tightens three new comments. No code change. phpmd was the only NEW finding of check:strict on 9d65af3 (1013 lines against 1000).
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.